Protocol
Mutual TLS (mTLS): client certificates and when to use them
Mutual TLS (mTLS) is TLS in which both ends present certificates: the server proves it is api.example.com, and the client proves it is, say, billing-service. It is the standard way to authenticate machines to machines because the credential is a private key that never leaves the client. Use a private CA; public certificates are being limited to server authentication.
How mTLS changes the TLS 1.3 handshake
mTLS adds one server message and two client messages to an otherwise normal TLS 1.3 handshake. TLS 1.3 was originally specified in RFC 8446; RFC 9846 (July 2026) now obsoletes it but keeps the same message flow.
Client Server
------ ------
ClientHello (key_share, signature_algorithms) -->
<-- ServerHello (key_share)
<-- {EncryptedExtensions}
<-- {CertificateRequest} <- mTLS
<-- {Certificate} server chain
<-- {CertificateVerify} server proves key
<-- {Finished}
{Certificate} client chain --> <- mTLS
{CertificateVerify} signature over transcript --> <- mTLS
{Finished} -->
[Application Data] <--> [Application Data]
{ } = encrypted with handshake keys [ ] = encrypted with application keys
What each mTLS message carries:
| Message | Sent by | Contents | Purpose |
|---|---|---|---|
| CertificateRequest | Server | signature_algorithms (required), optionally certificate_authorities with the distinguished names of acceptable CAs |
Asks the client for a certificate and says which CAs and algorithms are acceptable |
| Certificate | Client | The client's leaf certificate and any intermediates; may be empty if it has none | Presents the identity |
| CertificateVerify | Client | A signature over the handshake transcript with the client's private key | Proves possession of the key, binding it to this specific connection |
Three details matter in practice:
- The client certificate is encrypted in TLS 1.3. In TLS 1.2 it was sent before encryption started, so a passive observer could read the client's identity. TLS 1.3 sends it under handshake keys.
- An empty Certificate is allowed by the protocol. The server decides what to do. A strict server aborts with the
certificate_requiredalert (alert number 116); a lenient one continues and lets the application decide. - Post-handshake authentication exists but is limited. A TLS 1.3 client that sends the
post_handshake_authextension lets the server request a certificate later in the connection. RFC 8740 forbids this on HTTP/2, which is why "require a client certificate only on/admin" usually does not work with HTTP/2 and needs a separate hostname instead.
One-way TLS versus mutual TLS
One-way TLS authenticates the server; mTLS authenticates both sides with the same mechanism. The table compares them with the usual alternative, bearer tokens over one-way TLS.
| One-way TLS + API key or token | Mutual TLS | |
|---|---|---|
| Server authenticated | Yes, by certificate | Yes, by certificate |
| Client authenticated | By a secret sent inside the connection | By a certificate and a private-key signature in the handshake |
| What leaks if traffic is logged or a proxy is compromised | The token itself, reusable until revoked | Nothing reusable; the private key never leaves the client |
| Rejection point | Application layer, after TLS | TLS layer, before any HTTP request is parsed |
| Rotation | Replace the secret everywhere | Issue a new certificate; CA trust stays the same |
| Operational cost | Low | Needs a CA, issuance, renewal and revocation |
mTLS is not a replacement for authorization. It tells you which client is connecting; your application still decides what that client may do.
Where mTLS is used
mTLS shows up wherever both endpoints are machines run by the same organisation or by contracted partners.
- Service meshes. Sidecar proxies issue short-lived certificates to every workload and encrypt and authenticate all service-to-service traffic. Workload identity is usually carried in a URI SAN such as
spiffe://example.com/ns/prod/sa/billing. - Partner and banking APIs. Open-banking and payment APIs commonly require clients to present a certificate issued by the provider or a designated CA.
- OAuth 2.0. RFC 8705 defines two things: client authentication to the authorization server with a certificate (
tls_client_authfor PKI-issued certificates,self_signed_tls_client_authfor self-signed ones), and certificate-bound access tokens, where the token carries a hash of the client certificate in itscnfclaim (x5t#S256) so a stolen token is useless without the key. - Zero-trust access. Corporate devices hold a device certificate; the access proxy admits only connections that present one from the company CA.
- IoT and devices. Each device gets a unique certificate at manufacture; the backend identifies the device by certificate rather than by a shared password baked into firmware.
- Databases and message brokers. PostgreSQL, MySQL, Kafka and others support client certificate authentication natively.
Why mTLS belongs on a private CA
Use a private CA for client certificates. Publicly trusted certificates are being restricted to TLS server authentication, and even before that they were a poor fit: a public CA validates domain names, not your services or devices, and any customer of that CA could get a certificate that chains to the same root.
The policy changes as of November 2026:
| Date | Change | Source |
|---|---|---|
| Oct 1, 2025 | Let's Encrypt offers an opt-in tlsclient profile that keeps the clientAuth EKU for migration |
Let's Encrypt blog |
| Feb 11, 2026 | Let's Encrypt's default classic profile stops including the TLS Client Authentication EKU |
Let's Encrypt blog |
| Jun 15, 2026 | Chrome Root Program begins phasing out PKI hierarchies that violate its dedicated serverAuth rule; subordinate CAs disclosed from this date may assert only id-kp-serverAuth |
Chrome Root Program Policy 1.8 |
| Jul 8, 2026 | Let's Encrypt's tlsclient profile ends; no further certificates with clientAuth |
Let's Encrypt blog (updated March 16, 2026) |
| Mar 15, 2027 | Subscriber certificates issued under Chrome-trusted roots must assert only id-kp-serverAuth |
Chrome Root Program Policy 1.8 |
Let's Encrypt's Generation Y certificates do not include client authentication at all. Other public CAs are moving the same way on their Chrome-trusted hierarchies. If an mTLS setup suddenly rejects freshly renewed certificates with errors about key usage or purpose, see client auth EKU removed.
The fix is to separate the two jobs:
Public web PKI (browsers trust it) Private PKI (only your systems trust it)
---------------------------------- ----------------------------------------
Root (in browser root stores) Your offline root CA
└─ Intermediate (serverAuth only) └─ Issuing CA "Client CA"
└─ api.example.com ├─ billing-service (clientAuth)
EKU: serverAuth ├─ device-000123 (clientAuth)
└─ laptop-alice (clientAuth)
Server presents the public cert to clients. Server trusts ONLY the private Client CA
when verifying client certificates.
Trusting only a dedicated client CA on the server is also the security property you want: a certificate from any other CA, public or private, is rejected. Build a private CA with OpenSSL walks through the commands.
Configuring mTLS in nginx
nginx verifies client certificates with ssl_verify_client and a CA bundle. The CA file listed in ssl_client_certificate is also sent to clients in CertificateRequest as the list of acceptable CA names.
server {
listen 443 ssl;
server_name api.example.com;
ssl_certificate /etc/ssl/api.example.com/fullchain.pem; # public server cert
ssl_certificate_key /etc/ssl/api.example.com/privkey.pem;
ssl_client_certificate /etc/ssl/client-ca/ca-bundle.pem; # private client CA(s)
ssl_verify_client on; # on | optional | optional_no_ca | off
ssl_verify_depth 2;
ssl_crl /etc/ssl/client-ca/client-ca.crl; # optional revocation list
location / {
proxy_set_header X-Client-Verify $ssl_client_verify; # SUCCESS / FAILED:reason / NONE
proxy_set_header X-Client-DN $ssl_client_s_dn;
proxy_set_header X-Client-Serial $ssl_client_serial;
proxy_pass http://127.0.0.1:8080;
}
}
onrejects connections without a valid certificate; nginx returns400 No required SSL certificate was sentor400 The SSL certificate error.optionalrequests a certificate but lets the request through; check$ssl_client_verifyin the application. Useful during rollout.optional_no_caaccepts certificates that do not chain to your CA and leaves verification to the backend, which is rarely what you want.- Strip any incoming
X-Client-*headers at the edge so clients cannot forge them.
Testing with curl and openssl s_client
Test both the failure and the success case. Without a client certificate, a TLS 1.3 server that requires one aborts after the client's Finished, so the error appears on the first read:
openssl s_client -connect api.example.com:443 -servername api.example.com -brief
CONNECTION ESTABLISHED
Protocol version: TLSv1.3
Ciphersuite: TLS_AES_256_GCM_SHA384
Peer certificate: CN=api.example.com
Verification: OK
Negotiated TLS1.3 group: X25519MLKEM768
...:error:0A00045C:SSL routines:ssl3_read_bytes:tlsv13 alert certificate required:...:SSL alert number 116
Without -brief, s_client also prints Acceptable client certificate CA names, which is the fastest way to see which CA the server expects. With the certificate and key:
openssl s_client -connect api.example.com:443 -servername api.example.com \
-cert client.crt -key client.key -brief
curl --cert client.crt --key client.key https://api.example.com/health
If the client certificate is in a PKCS#12 file, curl accepts --cert-type P12 --cert client.p12:password; see PKCS#12 commands. Check that the client certificate really has the clientAuth purpose:
openssl x509 -in client.crt -noout -ext extendedKeyUsage
X509v3 Extended Key Usage:
TLS Web Client Authentication
More connection tests are collected in testing TLS connections.
Common mTLS failures
Most mTLS failures come from a mismatch between what the server trusts and what the client sends, not from the protocol. Match the symptom to the cause:
| Symptom | Likely cause | Fix |
|---|---|---|
alert certificate required (116) on the client |
Client sent no certificate: wrong path, key missing, or the client library never loaded it | Pass both certificate and key; for browsers, install the PKCS#12 bundle in the OS or browser store |
alert unknown ca (48) |
Client certificate does not chain to a CA in the server's trust list | Add the issuing CA (and intermediates) to the server's client CA bundle, or send the intermediate from the client |
alert unsupported certificate or purpose errors |
Client certificate lacks the clientAuth EKU, typically a renewed public certificate | Issue client certificates from a private CA with extendedKeyUsage=clientAuth |
| Browser shows no certificate picker | Server's certificate_authorities list does not match any installed certificate's issuer |
Check Acceptable client certificate CA names in s_client output |
| Works on HTTP/1.1, fails on HTTP/2 | Application relies on post-handshake (per-path) client authentication | Request the certificate during the handshake, or use a dedicated hostname |
| Fails only behind a load balancer | TLS terminates at the balancer, so the backend never sees the certificate | Verify at the balancer and forward the verified identity in a header the edge controls |
When a client certificate chain is the problem, the same tools you use for server chains apply; see verifying certificate chains.
Rotation and revocation
Plan for certificate renewal from the start, because client certificates are spread across many machines. The patterns that hold up:
- Short lifetimes plus automation. Service meshes rotate workload certificates every few hours or days, which makes revocation almost unnecessary. Internal ACME servers or a mesh's built-in CA can do the same for VMs.
- Overlap CA bundles during CA rotation. Add the new client CA to
ssl_client_certificatefirst, then start issuing from it, and remove the old CA only after every old certificate has expired. - Keep a revocation path for long-lived device certificates. Publish a CRL and load it with
ssl_crl, or keep a deny list in the application keyed by serial number or fingerprint. - Generate keys on the client. Have each device or service create its key and send a CSR; never email private keys around.
- Monitor expiry. An expired client certificate looks like an outage on the client side, often in a system nobody remembers owning.
For the handshake itself in more depth, read the TLS 1.3 handshake; for EKU and key usage fields, see extended key usage and client certificate.
FAQ
What is the difference between TLS and mTLS?
In ordinary TLS only the server proves its identity with a certificate. In mutual TLS the server also sends a CertificateRequest, and the client must present its own certificate and prove it holds the matching private key with a CertificateVerify signature. Both sides end up authenticated before any application data flows.
Can I use a Let's Encrypt certificate as a client certificate?
Not for new certificates. Let's Encrypt removed the TLS Client Authentication EKU from its default profile on February 11, 2026 and stopped issuing it entirely after July 8, 2026; its newer Generation Y hierarchy does not include client authentication. Use a private CA for client certificates.
Why does my server say 'No required SSL certificate was sent'?
That is nginx's 400 response when ssl_verify_client on is set and the client did not present a certificate. Check that the client is configured with both the certificate and its private key, and that the certificate chains to a CA listed in ssl_client_certificate.
Does mTLS work with HTTP/2?
Yes, as long as the client certificate is requested during the initial handshake. RFC 8740 forbids TLS 1.3 post-handshake authentication on HTTP/2 connections, so servers cannot ask for a client certificate later, for example only on one URL path.
Sources
- RFC 9846: The TLS Protocol Version 1.3 (obsoletes RFC 8446)
- RFC 8705: OAuth 2.0 Mutual-TLS Client Authentication and Certificate-Bound Access Tokens
- RFC 8740: Using TLS 1.3 with HTTP/2
- Chrome Root Program Policy
- Let's Encrypt: Ending TLS Client Authentication Certificate Support in 2026
- nginx: ngx_http_ssl_module
- OpenSSL s_client manual