L IntegrAuth Lab

🔏 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.