The idea: the client proves who it is too
In ordinary TLS, the kind every website uses, only the server shows a certificate. The browser learns who the server is; the server learns nothing about the client, and finds out later with a password or a token. This is one-way TLS. In mutual TLS (mTLS) the client also shows a certificate and proves it holds the private key, inside the same handshake. Both ends know who is on the other side before a single byte of the request is sent.
The canvas shows a service mesh: the client pod on the left, the payments server on the right, the mesh CA on top, and the wire in the middle, one row per message. Pick how the server runs TLS in the first select, which pod calls it in the second, and press Connect. It is 2026-10-21 10:00.
The three extra messages
- CertificateRequest (server → client). Sent in the server's encrypted flight, next to its own Certificate. Its
certificate_authoritiesextension names the CAs the server accepts, so a client with several certificates can pick the right one. In nginx this isssl_verify_client on; in Istio,PeerAuthenticationmodeSTRICT. - Certificate (client → server). The client's certificate chain. A client with no suitable certificate must still answer, with an empty Certificate message.
- CertificateVerify (client → server). A signature over the whole handshake so far, made with the client's private key. It proves the client owns the certificate: a copied certificate without the key is useless.
All three travel encrypted with the handshake keys, so in TLS 1.3 an observer cannot see which client certificate was used (in TLS 1.2 it was sent in clear). The handshake is still one round trip (Demo 2: mutual TLS: both sides prove who they are).
How the server checks a client certificate
Exactly as a client checks a server's (see TLS Certificate Chain and Revocation): the chain must end at a CA in the server's trust bundle, today must be inside the validity dates, and the signature in CertificateVerify must verify with the certificate's public key. Any failure ends the handshake with a fatal alert:
| what the client sent | check that fails | alert | demo |
|---|---|---|---|
| an empty Certificate | a certificate is required | certificate_required (116) | Demo 3 |
| a certificate signed by its own CA | issuer not in the trust bundle | unknown_ca (48) | Demo 4 |
| an expired certificate | validity dates | certificate_expired (45) | Demo 5 |
| someone else's certificate, no key | CertificateVerify signature | decrypt_error (51) | – |
The name written in a certificate means nothing by itself. In Demo 4 the rogue pod's certificate says orders, but it was signed by "Rogue CA", which the server does not trust. Only the signer makes the name believable.
Identity in a service mesh: SPIFFE and SVIDs
Between services nobody types a password, so the certificate itself becomes the identity of the workload. SPIFFE names each workload with a URI like spiffe://shop.example/ns/prod/sa/orders (trust domain, namespace, service account). The certificate that carries it in its URI subjectAltName is an X.509 SVID. A mesh (Istio, Linkerd, Consul, or SPIRE on its own) runs the CA, gives every sidecar its SVID and the trust bundle, and renews the SVID every few hours. The app itself just speaks plain HTTP to its sidecar.
Short lifetimes replace revocation: a stolen key is useful for hours, not months. The cost is that rotation must never stop. A sidecar that can't renew is locked out within a day (Demo 5: expired SVID (rotation stopped)).
Authentication is not authorization
A valid certificate answers who is calling. Whether that caller may call this endpoint is a separate decision, made after the handshake by an authorization policy that matches the SPIFFE ID (in Istio, an AuthorizationPolicy with principals). The inventory pod in Demo 6 has a perfect certificate and completes the handshake, then gets 403 RBAC: access denied because only orders may call /charge.
One-way vs mutual TLS
| one-way TLS | mutual TLS | |
|---|---|---|
| certificates | server only | server and client |
| extra messages | – | CertificateRequest, client Certificate, CertificateVerify |
| round trips | 1 (TLS 1.3) | 1 (TLS 1.3) |
| server knows the caller | no: only an IP address, then a password or token | yes: the identity in the certificate |
| a rogue pod inside the network | gets in (Demo 1) | stopped in the handshake (Demo 3, 4) |
| typical use | public websites and APIs | service to service, company VPNs and devices, banking APIs |
| hard part | one certificate per server | a certificate for every client, and rotating them all |
See How an HTTPS Connection Is Established for the whole TLS 1.3 handshake, key exchange included, and Digital Signatures for how CertificateVerify's signature is made and checked.
What the page leaves out
TLS 1.2's mutual TLS (clear-text client certificate, renegotiation), post-handshake client authentication, certificate selection by the certificate_authorities list, intermediate CAs in the mesh, revocation of client certificates, how SPIRE attests a workload before issuing its SVID, JWT-SVIDs, the mesh's PERMISSIVE mode (accepting both plain text and mTLS during migration), and federation between trust domains.