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.

Two TLS 1.3 handshakes side by side, time going down. Left, one-way TLS: ClientHello, then ServerHello with the server's Certificate, CertificateVerify and Finished, then the client's Finished; the server does not know the client. Right, mutual TLS: the server's flight also has CertificateRequest, and the client sends its own Certificate and CertificateVerify before Finished; both sides know each other. Both take one round trip
Mutual TLS adds three messages but no round trip: CertificateRequest from the server, then the client's own Certificate and CertificateVerify.

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_authorities extension names the CAs the server accepts, so a client with several certificates can pick the right one. In nginx this is ssl_verify_client on; in Istio, PeerAuthentication mode STRICT.
  • 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 sentcheck that failsalertdemo
an empty Certificatea certificate is requiredcertificate_required (116)Demo 3
a certificate signed by its own CAissuer not in the trust bundleunknown_ca (48)Demo 4
an expired certificatevalidity datescertificate_expired (45)Demo 5
someone else's certificate, no keyCertificateVerify signaturedecrypt_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.

The mesh CA at the top issues short-lived SVIDs to the sidecars of two pods, orders and payments, and gives both the same trust bundle. The orders app talks plain HTTP to its sidecar, the two sidecars talk mutual TLS, and the payments sidecar passes plain HTTP to the payments app with the caller's SPIFFE ID
The mesh CA hands each sidecar a short-lived SVID and the trust bundle; the sidecars do mutual TLS so the apps don't have to.

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 TLSmutual TLS
certificatesserver onlyserver and client
extra messages–CertificateRequest, client Certificate, CertificateVerify
round trips1 (TLS 1.3)1 (TLS 1.3)
server knows the callerno: only an IP address, then a password or tokenyes: the identity in the certificate
a rogue pod inside the networkgets in (Demo 1)stopped in the handshake (Demo 3, 4)
typical usepublic websites and APIsservice to service, company VPNs and devices, banking APIs
hard partone certificate per servera 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.