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.
- Server sends
CertificateRequestafterEncryptedExtensions(RFC 8446 section 4.3.2), withcertificate_authoritiesandsignature_algorithmshints for the client. - Client sends
Certificate(section 4.4.2); its list is empty if it has nothing suitable. The server may then continue unauthenticated or abort withcertificate_required(section 4.4.2.4): "required" is your configuration, not a default. - 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. ThenFinished. - 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. - 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
ClientCertVerifiersuch asWebPkiClientVerifier; theallow_unauthenticatedoption of its builder lets anonymous clients through. - Reload bundles and leaves without restart: watch files or the workload API stream, swap an
Arcfor new connections. - Revocation keys must be (issuer, serial): serials are unique only per issuer.
- Enforce
max_lifeserver-side: it bounds the damage of a CA that issues long-lived leaves. - Allow clock skew on
notBeforeonly (devices boot with wrong clocks); never extendnotAfter. - Per the SPIFFE X509-SVID spec, a leaf EKU, when present, includes
serverAuthandclientAuth; the code requiresclientAuth.
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 toSTRICTonce all workloads have sidecars.
Variants and alternatives
In production
- Istio runs mutual TLS between workloads (
PeerAuthenticationmodesPERMISSIVE,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-fullfor database clients. - FAPI 2.0 deployments use RFC 8705 mutual TLS for sender-constrained tokens.
Implementations
| name | lang | repo | install | verdict | licence | health | as_of | notes |
|---|---|---|---|---|---|---|---|---|
| rustls | Rust | rustls/rustls | cargo add rustls | — | — | — | — | WebPkiClientVerifier for client certificates |
| SPIRE | Go | spiffe/spire | release binaries from the repo | — | — | — | — | issues short-lived X509-SVIDs via Workload API |
| Envoy | C++ | envoyproxy/envoy | brew install envoy | — | — | — | — | client cert validation, XFCC forwarding |
| Istio | Go | istio/istio | brew install istioctl | — | — | — | — | PeerAuthentication modes |
| step-ca | Go | smallstep/certificates | release 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