Skip to content
ecdsa.com
← All articles

Mutual TLS for APIs: client certificates in practice

One private CA, five client certificates and four servers. Every configuration below was run on 20 October 2026, and every error message is the one nginx 1.30, Envoy 1.39, Go 1.27 and Node.js 24 actually printed.

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:

openssl s_client -connect 127.0.0.1:8443 -servername api.internal.test -CAfile root.pem (excerpt)
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:

ext.cnf (excerpt): the issuing CA and a client certificate profile
[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
pki.sh (excerpt): issue a one-day client certificate and its chain file
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

/etc/nginx/conf.d/mtls.conf (nginx 1.30.4)
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:

curl results, then mtls.log (timestamps and client addresses removed)
--- 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_page can turn into your own response.
  • The reason only appears in the error log, and only at level info. With the default error level 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 --cert takes a chain file) or the server adds the intermediate to its CA file.
  • ssl_verify_depth counts 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_certificate sends its CA list to clients; ssl_trusted_certificate verifies 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):

envoy.yaml (excerpt): mTLS listener with a SAN matcher and 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:

curl -i results and /stats (Envoy 1.39.4)
--- 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:

main.go (excerpt): server side (Go 1.27.3)
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:

go run . (ports replaced by PORT)
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

mtls.mjs (excerpt): server options and authorization (Node.js 24.22.0)
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 mtls.mjs (OpenSSL error locations shortened)
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 GetClientCertificate to 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.crt accepts 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 billing showed.
  • Matching names loosely. Compare a parsed URI SAN exactly; do not search the subject string for CN=worker or 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, nginx proxy_set_header overwrites).
  • A client certificate without clientAuth. If the certificate has an EKU without clientAuth, OpenSSL-based servers reject it: presenting the lab's serverAuth-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