Home › Security › Certificates & PKI
algorithm low

Mutual TLS

Problem A service, device or database that accepts connections from many callers must know which caller is on the other end before it reads a request, without sending a password or token on every connection.

TLS where the client also proves a certificate key, so every connection carries a verified, rotatable machine identity.

extra handshake cost: one client signature plus chain verification, order of 0.1-1 ms; identity check itself tens of ns
mtlsclient-certificatesservice-identityspiffepki
Overview

In ordinary TLS only the server shows a certificate. In mutual TLS the server also sends a CertificateRequest; the client answers with its certificate and a signature proving it holds the key. The peer's identity is known before any request byte and cannot be replayed on another connection. Service meshes, device fleets, open-banking APIs and database servers use it as their identity layer. The hard part is issuing, rotating and mapping certificates to permissions, not the handshake.

Key facts

Use when

  • Service-to-service calls in a mesh or cluster (Istio STRICT mode accepts only mutual TLS; Kubernetes API server client certificates).
  • IoT and device fleets: AWS IoT Core authenticates devices with X.509 client certificates, one per device recommended.
  • Regulated APIs: FAPI 2.0 allows RFC 8705 mutual TLS for client authentication and sender-constrained tokens.
  • Databases: PostgreSQL clientcert=verify-full requires a valid client certificate whose CN matches the user.

Avoid when

  • Callers are browsers or phones of end users: certificate distribution and UX fail; use passkeys or OAuth 2.0 / OIDC instead.
  • You cannot automate issuance and rotation.

Deep dive

How it works

In: a completed TLS 1.3 handshake with a client certificate chain · Out: an authenticated identity string or a typed rejection · State: trust anchors, revoked serials, policy table; no per-connection state · Cost: O(chain length + SANs) after the signature check; tens of ns in the code below.

  1. Server sends CertificateRequest after EncryptedExtensions (RFC 8446 section 4.3.2), with certificate_authorities and signature_algorithms hints for the client.
  2. Client sends Certificate (section 4.4.2); its list is empty if it has nothing suitable. The server may then continue unauthenticated or abort with certificate_required (section 4.4.2.4): "required" is your configuration, not a default.
  3. Client sends CertificateVerify (section 4.4.3): a signature over the transcript hash, proving possession of the private key and binding the identity to this handshake. Then Finished.
  4. Library verifies the chain to your private CA and the signature. Your code then checks what chain validation does not: extended key usage clientAuth, validity window and maximum lifetime, trust domain, exactly one URI SAN (SPIFFE rule), revocation, and the policy lookup identity to routes.
  5. Post-handshake: a client that offered post_handshake_auth (section 4.2.6) can be asked for a certificate later on the same connection (section 4.6.2), for example only for a sensitive path.

Identity. Map the certificate to one identity string: a SPIFFE ID in a URI SAN (spiffe://example.org/ns/pay/sa/api) or a DNS SAN. Kubernetes and PostgreSQL use the subject CN, which is safe only when your private CA sets it.

Issuance and rotation. A private CA signs short-lived leaf certificates; the workload gets them from a local agent (the SPIFFE Workload API, see workload identity) or by ACME against a private CA. SPIRE's default_x509_svid_ttl is 1 h and ca_ttl 24 h. Rotate at about half the lifetime: old and new certificate are both valid in the overlap, the server accepts either, and the client switches new connections only.

Termination. A proxy that ends TLS sees the certificate; the backend sees only headers. Envoy's x-forwarded-client-cert carries Hash, Subject, URI and DNS of the client certificate, and by default (forward_client_cert_details unset) the header is sanitized. Never trust an XFCC header that did not come from your own proxy over its own mutual-TLS hop. See TLS termination.

Tokens. RFC 8705 section 3 binds an OAuth access token to the certificate: the token carries cnf with x5t#S256, the base64url SHA-256 of the DER certificate. A stolen token is useless without the key. Rotating the certificate invalidates its bound tokens (section 6.3), so refresh them. How a terminating proxy passes certificate metadata on is out of scope (section 6.5).

Code

The server-side check after the handshake, on an already parsed certificate (issuer chain as ids; no TLS, no ASN.1). Every rejection path and a rotation overlap are asserted.

use std::collections::{HashMap, HashSet};

#[derive(PartialEq)]
enum Eku { ClientAuth, ServerAuth }
struct Cert { uri_sans: Vec<String>, eku: Vec<Eku>, not_before: u64, not_after: u64,
              chain: Vec<u32>, // issuer ids, leaf's issuer first, trust anchor last
              serial: u64 }
struct Trust { anchors: HashSet<u32>, prefix: String, max_life: u64, skew: u64,
               revoked: HashSet<(u32, u64)> } // (issuer id, serial): serials are unique per issuer only
#[derive(Debug, PartialEq)]
enum Reject { UntrustedIssuer, Revoked, NoClientAuth, NotYetValid, Expired, TooLong,
              BadUri, WrongDomain, NotAllowed }

fn check<'a>(c: &'a Cert, t: &Trust, policy: &Policy,
             route: &str, now: u64) -> Result<&'a str, Reject> {
    // 1. the TLS stack already proved key possession (CertificateVerify); this is the policy layer
    let (Some(&anchor), Some(&issuer)) = (c.chain.last(), c.chain.first()) else { return Err(Reject::UntrustedIssuer) };
    if !t.anchors.contains(&anchor) { return Err(Reject::UntrustedIssuer); }
    if t.revoked.contains(&(issuer, c.serial)) { return Err(Reject::Revoked); }
    if !c.eku.contains(&Eku::ClientAuth) { return Err(Reject::NoClientAuth); }
    if now + t.skew < c.not_before { return Err(Reject::NotYetValid); }   // skew helps only on the early side
    if now > c.not_after { return Err(Reject::Expired); }
    if c.not_after - c.not_before > t.max_life { return Err(Reject::TooLong); }
    // exactly one URI SAN, inside our trust domain, with a non-empty path (SPIFFE X509-SVID rules)
    let [id] = c.uri_sans.as_slice() else { return Err(Reject::BadUri) };
    let path = id.strip_prefix(t.prefix.as_str()).ok_or(Reject::WrongDomain)?;
    if path.is_empty() { return Err(Reject::BadUri); }
    // authentication done; authorization is a separate lookup
    match policy.get(id.as_str()) { Some(r) if r.contains(route) => Ok(id), _ => Err(Reject::NotAllowed) }
}

type Policy<'a> = HashMap<String, HashSet<&'a str>>;

fn main() {
    let t = Trust { anchors: [1].into(), prefix: "spiffe://example.org/".into(), max_life: 86_400,
                    skew: 60, revoked: [(7, 99)].into() };
    let policy: HashMap<String, HashSet<&str>> =
        [("spiffe://example.org/ns/pay/sa/api".to_string(), ["/charge"].into())].into();
    let good = |serial, nb, na| Cert { uri_sans: vec!["spiffe://example.org/ns/pay/sa/api".into()],
        eku: vec![Eku::ClientAuth], not_before: nb, not_after: na, chain: vec![7, 1], serial };
    macro_rules! ok { ($c:expr, $n:expr) => { check(&$c, &t, &policy, "/charge", $n) } }
    let id = "spiffe://example.org/ns/pay/sa/api";
    assert_eq!(ok!(good(1, 1000, 4600), 2000), Ok(id));
    // rotation overlap: old cert (until 4600) and new cert (from 4000) both accepted at 4300
    let (old, new) = (good(1, 1000, 4600), good(2, 4000, 7600));
    assert_eq!(ok!(old, 4300), Ok(id)); assert_eq!(ok!(new, 4300), Ok(id));
    assert_eq!(ok!(old, 4601), Err(Reject::Expired)); assert_eq!(ok!(new, 3900), Err(Reject::NotYetValid));
    assert_eq!(ok!(new, 3950), Ok(id)); // within 60 s skew
    assert_eq!(ok!(good(99, 1000, 4600), 2000), Err(Reject::Revoked));
    assert_eq!(ok!(good(1, 1000, 1000 + 86_401), 2000), Err(Reject::TooLong));
    let mut c = good(1, 1000, 4600); c.chain = vec![7, 2]; assert_eq!(ok!(c, 2000), Err(Reject::UntrustedIssuer));
    let mut c = good(1, 1000, 4600); c.eku = vec![Eku::ServerAuth]; assert_eq!(ok!(c, 2000), Err(Reject::NoClientAuth));
    let mut c = good(1, 1000, 4600); c.uri_sans.push("spiffe://example.org/x".into()); assert_eq!(ok!(c, 2000), Err(Reject::BadUri));
    let mut c = good(1, 1000, 4600); c.uri_sans = vec!["spiffe://evil.org/ns/pay/sa/api".into()]; assert_eq!(ok!(c, 2000), Err(Reject::WrongDomain));
    let mut c = good(1, 1000, 4600); c.uri_sans = vec!["spiffe://example.org/".into()]; assert_eq!(ok!(c, 2000), Err(Reject::BadUri));
    assert_eq!(check(&good(1, 1000, 4600), &t, &policy, "/refund", 2000), Err(Reject::NotAllowed));
    let c = good(1, 1000, 4600); let n = 2_000_000u64; let s = std::time::Instant::now();
    let mut acc = 0; for i in 0..n { acc += ok!(c, 2000 + (i & 1)).is_ok() as u64; }
    println!("{} ns/check ({acc} ok)", s.elapsed().as_nanos() as u64 / n);
}

Complexity and constants

One check is a handful of integer compares, one HashSet probe for revocation, a prefix compare on the URI and one HashMap probe on the identity string: about 40 ns per check measured (Apple M4 Pro, release build, hot cache, SipHash, one 35-byte URI). The handshake dominates: one client signature, one to three signature verifies on the server (order of tens of µs each, depending on the algorithm; not measured here), and an extra flight of certificate bytes (order of 1 kB per certificate). Resumption skips the certificate exchange, so ticket lifetime should stay below certificate lifetime.

Efficiency

Cost: one extra signature and 1-3 verifies per full handshake, a few kB more handshake data, nothing per request afterwards. Bound: server CPU during handshake storms after restarts.

Levers:

  • Connection reuse (keep-alive, HTTP/2) — handshake cost spread over many requests — always.
  • Session resumption — skips certificates and signature checks (RFC 8446 section 2.2) — when identity cannot change within the ticket lifetime.
  • ECDSA P-256 or Ed25519 leaf keys — smaller certificates and cheaper verifies than RSA-2048 (RFC 8032 and digital signatures) — new fleets.
  • Short lifetimes instead of CRL/OCSP fetches — removes the revocation lookup from the data path (revocation) — when the issuer can sign at your rotation rate.
  • Cache the accepted identity on the connection — no SAN parsing per request — proxies and meshes.

Naive → standard → state of the art:

  • Parse and verify the full chain on every request, fetch OCSP live · adds a network round trip per request
  • Verify once per connection, hours-long certificates, CRL checks cached · one verify per connection
  • Verify once, 1-hour certificates from a workload API, rotation in the sidecar, identity cached per connection · no revocation traffic, no per-request cost

Implementation notes

  • Make client auth mandatory in the library: rustls needs a ClientCertVerifier such as WebPkiClientVerifier; the allow_unauthenticated option of its builder lets anonymous clients through.
  • Reload bundles and leaves without restart: watch files or the workload API stream, swap an Arc for new connections.
  • Revocation keys must be (issuer, serial): serials are unique only per issuer.
  • Enforce max_life server-side: it bounds the damage of a CA that issues long-lived leaves.
  • Allow clock skew on notBefore only (devices boot with wrong clocks); never extend notAfter.
  • Per the SPIFFE X509-SVID spec, a leaf EKU, when present, includes serverAuth and clientAuth; the code requires clientAuth.

Pitfalls

  • "Valid chain" is not "authorized": any certificate from the CA authenticates; you still need a policy lookup.
  • Trusting the system CA store for client certificates: use a dedicated client CA bundle.
  • Rotation without overlap: connections fail at the expiry instant; renew at half life.
  • Trusting XFCC or similar headers from untrusted hops.
  • Istio mesh-wide unset mode is PERMISSIVE, which also accepts plain text; move to STRICT once all workloads have sidecars.

Variants and alternatives

In production

  • Istio runs mutual TLS between workloads (PeerAuthentication modes PERMISSIVE, STRICT, DISABLE).
  • SPIRE issues SPIFFE X.509 SVIDs to workloads over the Workload API.
  • AWS IoT Core authenticates devices by X.509 client certificates, generated by AWS IoT or signed by your own CA.
  • Kubernetes API server accepts clients signed by --client-ca-file; username comes from the CN.
  • PostgreSQL hostssl ... clientcert=verify-full for database clients.
  • FAPI 2.0 deployments use RFC 8705 mutual TLS for sender-constrained tokens.

Implementations

namelangrepoinstallverdictlicencehealthas_ofnotes
rustlsRustrustls/rustlscargo add rustls————WebPkiClientVerifier for client certificates
SPIREGospiffe/spirerelease binaries from the repo————issues short-lived X509-SVIDs via Workload API
EnvoyC++envoyproxy/envoybrew install envoy————client cert validation, XFCC forwarding
IstioGoistio/istiobrew install istioctl————PeerAuthentication modes
step-caGosmallstep/certificatesrelease binaries from the repo————private CA, short-lived certificates

Sources

  • Rescorla, The Transport Layer Security (TLS) Protocol Version 1.3, RFC 8446, 2018. RFC
  • Campbell et al., OAuth 2.0 Mutual-TLS Client Authentication and Certificate-Bound Access Tokens, RFC 8705, 2020. RFC
  • SPIFFE, The X.509 SVID. spec
  • OpenID Foundation, FAPI 2.0 Security Profile. spec
  • Envoy docs, HTTP header manipulation: x-forwarded-client-cert. docs
  • Istio docs, Security concepts: peer authentication. docs
  • PostgreSQL docs, pg_hba.conf. docs
  • AWS docs, X.509 client certificates. docs
  • Kubernetes docs, Authenticating. docs

Used by

Systems (350):

Alternatives and related

TLS 1.3Any network service must keep traffic between clients, or between internal services, private, tamper-proof and authenticated to the right server, without hand-building a secure channel.
x509 certificates · plannedCertificate chains, path validation, SANs, revocation and short-lived issuance
certificate revocation · plannedCRLs, OCSP, OCSP stapling, short-lived certificates and CRLite-style filters for revoked certificates
acme protocol · plannedAutomated TLS certificate issuance and renewal via ACME HTTP-01/DNS-01 challenges
JSON Web Token (JWT)A server must accept requests from clients or other services it holds no stored state for, and decide who sent them, for whom they are meant and until when, without a database lookup per request.
tls termination · plannedTerminate TLS at a proxy: SNI cert selection, session resumption, OCSP, kTLS offload (listed in Moved to the wiki)
OAuth 2 and OpenID ConnectTeams adding "Sign in with Google/Microsoft/Okta", enterprise SSO, or letting third parties call their API for users must choose a safe login and delegated-access flow. This guide answers which flow to use and what goes wrong.Authorization models (RBAC, ABAC, ReBAC)An app with several kinds of users or shared data must decide who may do what to which object. This guide helps choose between roles, attributes and relationships, and where to put the checks.
key rotation · plannedRotating keys without downtime: key ids, overlap windows, re-wrapping, JWKS publication, revocation