🔏 mTLS-bound tokens
A bearer token is a bearer instrument — steal it, use it. A certificate-bound token is sender-constrained: it carries a cnf claim naming the
SHA-256 thumbprint of a client certificate, and the resource only accepts it when the SAME
certificate is presented again.
Honest simulation — read this first
Real mTLS proves you hold a certificate's private key during the TLS handshake
itself, at the network edge — on Cloudflare that's request.cf.tlsClientAuth.
This sandbox has no live TLS handshake to terminate, so "presenting a certificate" below just
means picking a fixed sample PEM and sending it along — the same shape an mTLS-terminating proxy
forwards inland (nginx's X-SSL-CLIENT-CERT, Envoy's x-forwarded-client-cert). The binding check below
— comparing a token's cnf.x5t#S256 against a re-presented cert's
thumbprint — is fully real. What isn't: cryptographic proof you hold that cert's private key.
Want proof-of-possession this Lab CAN fully simulate end to end, in-band? See DPoP (P12, sender-constrained) — same goal, a signed proof instead of a TLS handshake.
DPoP goes further: the server-issued nonce challenge (RFC 9449 §8)
A DPoP proof alone still lets a client mint proofs far in advance — outside the ±300s iat window, that's caught, but WITHIN it, a batch of pre-generated
proofs could sit ready to fire. §8's fix: the authorization server can also demand a fresh, server-issued nonce claim in every proof.
A request that omits it (or carries a stale/forged one) gets refused with {"error":"use_dpop_nonce"} and a DPoP-Nonce response header — the client mints one more proof, this
time WITH that nonce, and retries. Now a proof can only be minted after talking to the
server, defeating pre-generation entirely.
This Lab's token endpoint implements the full challenge/retry cycle
(src/lib/server/dpop.ts + crypto/dpop-nonce.ts): the nonce is stateless — a signed window.HMAC-SHA256(window) pair, keyed off the same secret that
encrypts TOTP seeds at rest — verified by recomputing the HMAC, no extra table. It's an authorization-server policy, off by default
(LAB_DPOP_NONCE_REQUIRED) so every existing DPoP integration —
including P12 below — keeps working unchanged; a deployment opts in when it wants to close the
pre-generation gap. A valid nonce never rescues a proof that's wrong for any OTHER reason (bad
signature, wrong URL, a replayed jti) — it's one more requirement,
never a bypass. See the oidc.dpop_nonce_challenged / oidc.dpop_nonce_accepted events in the X-ray panel when this policy
is active.
1. Issue a bound token
Pick a sample client certificate. The Lab hashes its DER bytes (SHA-256, base64url) and mints a
5-minute ES256 token whose cnf.x5t#S256 claim carries that thumbprint.
2. Access the resource
Call the protected resource with the token above, presenting a certificate of your choice. Present the same certificate you issued the token for → success. Present a different one (or none) → refused, loudly.
Issue a token above first.