Short answer. In mutual TLS (mTLS) the server sends a CertificateRequest, and the client answers with its certificate chain and a signature over the handshake, proving it holds the private key. The server validates that chain against its own trust anchor, a private CA, and then maps the certificate to an identity, ideally a single URI SAN such as a SPIFFE ID. Get four things right: the client must send its intermediate (or the server must have it), the server trusts only the CA that issues client certificates, authorization happens after authentication (a valid certificate is not permission), and certificates are short-lived and renewed automatically. Do not use public TLS certificates for clients: the public hierarchies are dropping clientAuth.
The lab ran in pinned images: debian:forky-20261005-slim (OpenSSL 3.6.5, nginx 1.30.4, curl 8.22.0), envoyproxy/envoy:v1.39.4, golang:1.27.3-trixie and node:24.22.0-bookworm-slim. All keys and certificates were generated for it.
Where mTLS fits
mTLS authenticates a machine, a workload or a device holding a key, at the connection level, before any HTTP request is parsed. That makes it a good fit for:
- Service-to-service traffic inside a platform, where a mesh or sidecar (Istio, Linkerd, Consul, plain Envoy) issues and rotates certificates for every workload.
- Partner and B2B APIs, where each partner gets a certificate from your CA; the FAPI 2.0 security profile accepts mTLS both for client authentication (next to
private_key_jwt) and for binding access tokens (next to DPoP). - Devices and agents that can keep a key in hardware (a TPM, a secure element) and present it on every connection.
It fits browsers poorly: users see certificate pickers, and a CDN or load balancer that terminates TLS in front of you must itself support client certificates. When the connection is not end to end, request-level proofs such as DPoP or HTTP Message Signatures travel better (see the end of this page).
What the handshake adds
In TLS 1.3 (RFC 9846, which replaced RFC 8446 in July 2026) the server's CertificateRequest comes right after EncryptedExtensions. It must carry signature_algorithms and may list the CA names it accepts in certificate_authorities (§4.4.2). The client replies with Certificate, CertificateVerify (its signature over the transcript) and Finished. If the client sends nothing, the server "MAY at its discretion either continue the handshake without client authentication, or abort the handshake with a certificate_required alert" (§4.5.1.3).
openssl s_client shows what a server asks for. From the nginx server below:
Acceptable client certificate CA names
O=Example, CN=Example Internal Root
Requested Signature Algorithms: id-ml-dsa-65:id-ml-dsa-87:id-ml-dsa-44:ECDSA+SHA256:ECDSA+SHA384:ECDSA+SHA512:ed25519:ed448:...
Two consequences matter in practice. First, clients use that CA list to choose a certificate, and some refuse to send one that does not chain to a listed name (Go does, as shown below). Second, in TLS 1.3 the client sends its certificate after it has received the server's Finished, so from the client's point of view the handshake is complete before the server has judged the certificate. Rejections therefore surface on the client's first read, as an alert, which is why curl reports them as (56) ... SSL_read rather than as a handshake error.
A private CA for client certificates
Client certificates come from a CA you run (or rent as a managed private CA), not from a public one. Public CAs cannot validate "this is our billing worker", and the public TLS hierarchies are removing client authentication: the Chrome Root Program requires serverAuth-only subscriber certificates from 15 March 2027, and Let's Encrypt stopped issuing clientAuth on 8 July 2026 (see X.509 certificate fields).
The lab hierarchy: a root, an issuing CA with pathlen:0, a server certificate for api.internal.test, and client certificates whose identity is a URI SAN in the SPIFFE format:
[issuing]
basicConstraints = critical, CA:TRUE, pathlen:0
keyUsage = critical, keyCertSign, cRLSign
extendedKeyUsage = serverAuth, clientAuth
subjectKeyIdentifier = hash
authorityKeyIdentifier = keyid
[worker7]
basicConstraints = critical, CA:FALSE
keyUsage = critical, digitalSignature
extendedKeyUsage = clientAuth
subjectAltName = URI:spiffe://example.test/ns/prod/sa/worker-7
authorityKeyIdentifier = keyid
openssl genpkey -algorithm EC -pkeyopt ec_paramgen_curve:P-256 -out worker-7.key
openssl req -new -key worker-7.key -subj "/O=Example/CN=worker-7" -out worker-7.csr
openssl x509 -req -in worker-7.csr -CA issuing.pem -CAkey issuing.key -extfile ext.cnf -extensions worker7 -days 1 -out worker-7.pem
cat worker-7.pem issuing.pem > worker-7-chain.pem
A URI SAN gives a typed, unambiguous identity; SPIFFE's X.509-SVID requires exactly one and says validators "MUST reject" certificates with more. A full SVID as SPIRE issues it also sets serverAuth in the EKU. Besides worker-7, the lab issued billing (same CA, different SPIFFE ID), intruder (same name and ID, signed by an unrelated root), expired (valid on 1 September 2026) and worker-9 under a second issuing CA two levels below the root.
nginx
server {
listen 8443 ssl;
server_name api.internal.test;
error_log /var/log/nginx/mtls.log info; # client certificate errors are logged at "info"
ssl_certificate /w/pki/server-chain.pem;
ssl_certificate_key /w/pki/server.key;
ssl_client_certificate /w/pki/root.pem; # trust anchor for client certificates
ssl_verify_client on;
ssl_verify_depth 2;
location / {
default_type text/plain;
return 200 "verify=$ssl_client_verify subject=$ssl_client_s_dn issuer=$ssl_client_i_dn\n";
}
}
Each case with curl --cert ... --key ..., then the log:
--- no client certificate
<head><title>400 No required SSL certificate was sent</title></head>
--- worker-7, leaf only
<head><title>400 The SSL certificate error</title></head>
--- worker-7 with its intermediate
verify=SUCCESS subject=CN=worker-7,O=Example issuer=CN=Example Issuing CA 1,O=Example
--- certificate from another CA
<head><title>400 The SSL certificate error</title></head>
--- expired certificate
<head><title>400 The SSL certificate error</title></head>
[info] client sent no required SSL certificate while reading client request headers
[info] client SSL certificate verify error: (21:unable to verify the first certificate) while reading client request headers
[info] client SSL certificate verify error: (21:unable to verify the first certificate) while reading client request headers
[info] client SSL certificate verify error: (10:certificate has expired) while reading client request headers
Things to notice:
- nginx finishes the handshake and answers with HTTP 400 (No required SSL certificate was sent); internally these are the non-standard codes 496 (no certificate) and 495 (verification failed), which
error_pagecan turn into your own response. - The reason only appears in the error log, and only at level
info. With the defaulterrorlevel you see nothing. - Leaf only fails. The server trusts the root; without the intermediate there is no path, hence error 21. Either the client sends
leaf + intermediate(curl's--certtakes a chain file) or the server adds the intermediate to its CA file. ssl_verify_depthcounts intermediates. The default is 1. We re-ran with depth 1:worker-7(one intermediate) still passed,worker-9(two intermediates) failed with(20:unable to get local issuer certificate)and passed at depth 2.ssl_client_certificatesends its CA list to clients;ssl_trusted_certificateverifies against the same kind of file without advertising it (nginx docs). Use the first when clients need the hint to pick a certificate.
To pass the identity on, proxy_set_header X-Client-Cert $ssl_client_escaped_cert; forwards the URL-encoded PEM, and $ssl_client_verify is SUCCESS, FAILED:reason or NONE. With ssl_verify_client optional, check $ssl_client_verify yourself before proxying.
Envoy
Envoy can also match the identity during the handshake and hand it to the application in x-forwarded-client-cert (XFCC):
transport_socket:
name: envoy.transport_sockets.tls
typed_config:
"@type": type.googleapis.com/envoy.extensions.transport_sockets.tls.v3.DownstreamTlsContext
require_client_certificate: true
common_tls_context:
tls_params: { tls_minimum_protocol_version: TLSv1_3 }
tls_certificates:
- certificate_chain: { filename: /w/pki/server-chain.pem }
private_key: { filename: /w/pki/server.key }
validation_context:
trusted_ca: { filename: /w/pki/root.pem }
match_typed_subject_alt_names:
- san_type: URI
matcher: { exact: "spiffe://example.test/ns/prod/sa/worker-7" }
# in the http_connection_manager of the same listener:
forward_client_cert_details: SANITIZE_SET
set_current_client_cert_details: { uri: true }
The backend in the lab is a second Envoy listener that echoes the XFCC header it received. Results with curl 7.81.0 from the Envoy image, then Envoy's listener statistics:
--- worker-7
HTTP/1.1 200 OK
x-backend-saw-xfcc: Hash=048c219f601a877b19c77fc8834750cdc6b1394b9080a50fa8eaeaf6f3793bd0;URI=spiffe://example.test/ns/prod/sa/worker-7
--- worker-7 sending its own x-forwarded-client-cert
HTTP/1.1 200 OK
x-backend-saw-xfcc: Hash=048c219f601a877b19c77fc8834750cdc6b1394b9080a50fa8eaeaf6f3793bd0;URI=spiffe://example.test/ns/prod/sa/worker-7
--- billing (valid certificate, SAN not allowed)
curl: (56) OpenSSL SSL_read: error:0A000416:SSL routines::sslv3 alert certificate unknown, errno 0
--- no client certificate
curl: (56) OpenSSL SSL_read: error:0A00045C:SSL routines::tlsv13 alert certificate required, errno 0
--- certificate from another CA
curl: (56) OpenSSL SSL_read: error:0A000418:SSL routines::tlsv1 alert unknown ca, errno 0
listener.127.0.0.1_10443.ssl.fail_verify_error: 1
listener.127.0.0.1_10443.ssl.fail_verify_no_cert: 1
listener.127.0.0.1_10443.ssl.fail_verify_san: 1
listener.127.0.0.1_10443.ssl.handshake: 2
The second request is the important one: the client sent its own XFCC header claiming to be admin, and SANITIZE_SET replaced it with what the proxy verified. Any proxy that forwards client identity in a header must strip the incoming copy; an application must accept that header only from the proxy. Each failure maps to an alert (certificate required, unknown ca, certificate unknown for the SAN mismatch) and to a separate counter, which is where to look first in production.
Go
tls.RequireAndVerifyClientCert with a ClientCAs pool does the verification; authorization by SPIFFE ID is ordinary handler code:
var allowed = map[string]string{"spiffe://example.test/ns/prod/sa/worker-7": "/jobs"}
func handler(w http.ResponseWriter, r *http.Request) {
// With RequireAndVerifyClientCert, VerifiedChains[0][0] is the verified client leaf.
leaf := r.TLS.VerifiedChains[0][0]
if len(leaf.URIs) != 1 {
http.Error(w, "client certificate must carry exactly one URI SAN", http.StatusForbidden)
return
}
id := leaf.URIs[0].String()
if allowed[id] != r.URL.Path {
http.Error(w, id+" may not call "+r.URL.Path, http.StatusForbidden)
return
}
fmt.Fprintf(w, "hello %s, cert valid until %s\n", id, leaf.NotAfter.Format(time.RFC3339))
}
func server(ln net.Listener) {
cert, err := tls.LoadX509KeyPair(pki+"server-chain.pem", pki+"server.key")
if err != nil {
log.Fatal(err)
}
srv := &http.Server{
Handler: http.HandlerFunc(handler),
TLSConfig: &tls.Config{
Certificates: []tls.Certificate{cert},
ClientAuth: tls.RequireAndVerifyClientCert,
ClientCAs: pool("root.pem"),
MinVersion: tls.VersionTLS13,
},
ErrorLog: log.New(os.Stdout, "server log: ", 0),
}
srv.ServeTLS(ln, "", "")
}
The same program's client sets tls.Config.Certificates and calls /jobs with each certificate; for one extra case it uses GetClientCertificate to send a certificate unconditionally:
server log: http: TLS handshake error from 127.0.0.1:PORT: tls: client didn't provide a certificate
no client certificate client error: Get "https://127.0.0.1:PORT/jobs": remote error: tls: certificate required
worker-7 200 hello spiffe://example.test/ns/prod/sa/worker-7, cert valid until 2026-10-21T15:17:06Z
billing 403 spiffe://example.test/ns/prod/sa/billing may not call /jobs
server log: http: TLS handshake error from 127.0.0.1:PORT: tls: client didn't provide a certificate
certificate from another CA client error: Get "https://127.0.0.1:PORT/jobs": remote error: tls: certificate required
server log: http: TLS handshake error from 127.0.0.1:PORT: tls: failed to verify certificate: x509: certificate signed by unknown authority
same, sent anyway client error: Get "https://127.0.0.1:PORT/jobs": remote error: tls: unknown certificate authority
server log: http: TLS handshake error from 127.0.0.1:PORT: tls: failed to verify certificate: x509: certificate has expired or is not yet valid: current time 2026-10-20T15:18:44Z is after 2026-09-02T00:00:00Z
expired certificate client error: Get "https://127.0.0.1:PORT/jobs": remote error: tls: expired certificate
Go's client did not send the intruder certificate at all: none of the certificates in its chain was issued by a CA in the server's certificate_authorities list, so it answered with an empty Certificate message and the server saw "client didn't provide a certificate". When a correctly configured Go client reports certificate required, compare its chain's issuers with the server's CA list before anything else. Forced through, the certificate failed as unknown certificate authority. billing shows the split between authentication (the certificate is valid) and authorization (403).
Node.js
function authorize(req, res) {
// With rejectUnauthorized: false the handshake succeeds for any certificate (or none),
// so this check is the only thing standing between the request and the handler.
if (!req.socket.authorized) {
res.writeHead(401).end(`client certificate rejected: ${req.socket.authorizationError}\n`);
return;
}
const cert = req.socket.getPeerCertificate();
// subjectaltname is a string such as "URI:spiffe://..." — parse it, do not substring-match it.
const uris = (cert.subjectaltname ?? "").split(", ").filter((n) => n.startsWith("URI:")).map((n) => n.slice(4));
const id = uris.length === 1 ? uris[0] : null;
if (!id || ALLOWED.get(id) !== req.url) {
res.writeHead(403).end(`${id ?? "no single URI SAN"} may not call ${req.url}\n`);
return;
}
res.end(`hello ${id}, serial ${cert.serialNumber}\n`);
}
const serverOptions = (rejectUnauthorized) => ({
key: pki("server.key"),
cert: pki("server-chain.pem"),
ca: pki("root.pem"), // trust anchor for client certificates
requestCert: true, // send CertificateRequest
rejectUnauthorized, // true: drop connections whose client certificate does not verify
minVersion: "TLSv1.3",
});
The lab ran the same five calls against a server with rejectUnauthorized: true and one with false:
node v24.22.0, OpenSSL 3.5.8
### rejectUnauthorized: true
server tlsClientError: ERR_SSL_PEER_DID_NOT_RETURN_A_CERTIFICATE
no client certificate client error: ERR_SSL_TLSV13_ALERT_CERTIFICATE_REQUIRED: ...:tlsv13 alert certificate required:...:SSL alert number 116
worker-7 200 hello spiffe://example.test/ns/prod/sa/worker-7, serial 7D2333CCB4C609E16E5592C959C71250AD4703BA
billing 403 spiffe://example.test/ns/prod/sa/billing may not call /jobs
server tlsClientError: ECONNRESET
certificate from another CA client error: ECONNRESET: socket hang up
server tlsClientError: ECONNRESET
expired certificate client error: ECONNRESET: socket hang up
### rejectUnauthorized: false
no client certificate 401 client certificate rejected: UNABLE_TO_GET_ISSUER_CERT
worker-7 200 hello spiffe://example.test/ns/prod/sa/worker-7, serial 7D2333CCB4C609E16E5592C959C71250AD4703BA
billing 403 spiffe://example.test/ns/prod/sa/billing may not call /jobs
certificate from another CA 401 client certificate rejected: UNABLE_TO_VERIFY_LEAF_SIGNATURE
expired certificate 401 client certificate rejected: CERT_HAS_EXPIRED
With rejectUnauthorized: true, Node rejects a bad certificate by closing the socket: no alert, no reason on either side, just ECONNRESET. A missing certificate is caught by OpenSSL earlier and gets a proper certificate required alert (peer did not return a certificate on the server). With false, every connection is accepted and socket.authorizationError carries the reason; that mode is safe only if every request path checks socket.authorized first. Keep true in production and use false with the check on a diagnostics port when you need the reason.
Issuing and rotating client certificates
- Short lifetimes, automated renewal. Client certificates for services should live hours to days and be renewed by an agent, not by a person: SPIRE for SPIFFE IDs, cert-manager in Kubernetes, step-ca, HashiCorp Vault's PKI engine, or a cloud private CA. A one-day certificate rarely needs revocation; if one does, nginx can check a CRL with
ssl_crl. - Overlap at every step. Renew at about two-thirds of the lifetime, and let the client load the new certificate without a restart (Envoy and SPIRE use the SDS API for this; Go can use
GetClientCertificateto read the current file). - Rotating the CA. Add the new root to every server's trust file first, then start issuing from it, then remove the old root once the last certificate from it has expired. Servers trust a bundle, so both roots can be valid at once.
- One CA per purpose. A CA that issues client certificates for one API should not also issue for another system whose servers would then accept them. Envoy's SAN matchers or your own authorization map are the second line.
Common mistakes
- Trusting the system store for clients.
ssl_client_certificate /etc/ssl/certs/ca-certificates.crtaccepts any certificate from any public CA, and a public certificate says nothing about your clients. - Authenticating without authorizing. Every valid certificate from your CA gets in unless a rule says which identity may call what, as
billingshowed. - Matching names loosely. Compare a parsed URI SAN exactly; do not search the subject string for
CN=workeror match a prefix of an attacker-chosen value. - Trusting a client-identity header from anyone. Only the proxy that terminated mTLS may set it, and it must remove the incoming copy (Envoy
SANITIZE_SET, nginxproxy_set_headeroverwrites). - A client certificate without
clientAuth. If the certificate has an EKU withoutclientAuth, OpenSSL-based servers reject it: presenting the lab'sserverAuth-only server certificate as a client certificate made nginx log(26:unsuitable certificate purpose), OpenSSL's error 26. - TLS-terminating load balancers in front of the server. The certificate check happens at the first TLS endpoint. If that is a load balancer, it has to perform mTLS and forward the identity, or pass TCP through untouched.
For OAuth, RFC 8705 builds on mTLS twice: clients authenticate to the token endpoint with a certificate (tls_client_auth), and access tokens can be bound to it (cnf with x5t#S256, the SHA-256 thumbprint), so a stolen token is useless without the key. Where mTLS cannot reach end to end, DPoP gives a similar binding at the HTTP layer, and HTTP Message Signatures sign individual requests.
Sources
- RFC 9846: TLS 1.3: §4.4.2 (CertificateRequest), §4.5.1.3 (receiving a Certificate message), §6 (alerts)
- nginx ngx_http_ssl_module:
ssl_verify_client,ssl_verify_depth,ssl_client_certificate,ssl_trusted_certificate, errors 495 and 496 - Envoy: TLS transport socket and x-forwarded-client-cert
- Go crypto/tls; Node.js tls.createServer
- SPIFFE X.509-SVID
- RFC 8705: OAuth 2.0 mutual-TLS client authentication and certificate-bound access tokens
- Chrome Root Program Policy 1.8; Let's Encrypt, Ending TLS client authentication certificate support in 2026