Demo · DPoP (RFC 9449)

Holder-of-key proof for authenticated APIs

The companion demo to the HMAC anti-abuse gate. That one protects the pre-auth surface where you don't know who the user is yet. This one protects the post-auth surface, where the user has logged in and the worry shifts from "scrapers cost me money" to "a stolen access token gives the attacker my user's identity, in full, until the token expires."

Bearer tokens are bearer instruments

An OAuth access token is, by design, anything-goes. The resource server is told "if you see this token, treat it as the user." Whoever holds the bytes wins. That's fine if the token never leaks. It isn't fine when:

Rotation helps but doesn't solve it: a short-lived token still works until it expires, and the attacker often has more compute than your refresh cadence.

What DPoP does

RFC 9449 (DPoP — Demonstrating Proof-of-Possession) binds an access token to a key the client holds. To call a protected endpoint, the client doesn't just send the bearer token; it also sends a freshly-signed DPoP proof JWT, signed with the private key whose public counterpart's thumbprint is embedded in the access token's cnf.jkt claim.

The proof covers the request method (htm) and URL (htu), carries a unique jti per request, and is timestamped with iat. Without the private key, an attacker who steals the access token alone cannot mint a valid proof — and without a valid proof, the resource server returns 401.

POST /resource HTTP/1.1
Host: api.example.com
Authorization: DPoP eyJhbGciOi…             ← the access token (cnf.jkt = thumbprint of client's public key)
DPoP:          eyJ0eXAiOi…                  ← a fresh, per-request proof signed by the client's PRIVATE key

The proof itself is a compact JWS (header.payload.signature). Inside:

// header
{
  "typ": "dpop+jwt",         // mandatory — defends against cross-context JWS confusion
  "alg": "ES256",            // asymmetric only — never HS*
  "jwk": { /* public key */ }
}
// payload
{
  "htm": "POST",                                  // method being called
  "htu": "https://api.example.com/resource",      // URL being called
  "iat": 1716902400,                              // when the proof was minted (server checks ±60s)
  "jti": "f47ac10b-58cc-4372-a567-0e02b2c3d479",  // unique per request — server tracks for replay
  "ath": "fUHyO2r2Z3DZ53EsNrWBb0xWXoaNy59IiKCAqksmQEo" // required when paired with an access token:
                                                      // base64url(SHA-256(access_token)) — binds proof to THIS token
}

The server-side checks come straight from RFC 9449 §4.3: parse as a JWS, verify the signature against the embedded JWK, confirm typ, compare htm / htu to the request being made, check iat for clock skew, reject jti if seen within the replay window, and (when an access token is present) verify the JWK's thumbprint matches cnf.jkt and the proof's ath claim equals SHA-256(access_token).

Try it · two views of DPoP

The page below is split into two sections.

Section A — the verification primitive. A pedagogical view of just the RFC 9449 proof-validation step in isolation. You generate a keypair, fill in claims, and the server tells you which RFC 9449 §4.3 checks pass. The "access token" used here is a fake the page hand-builds with whatever cnf.jkt you choose — useful for showing how the binding rule shape works, but the token isn't server-issued, so the cnf.jkt check is structurally self-referential.

Section B — real DPoP deployment. The same proof endpoint, but the access token is now minted by the server via a simulated login. The cnf.jkt binding is real (server-issued thumbprint vs client-signed proof), and the page includes the attack scenarios DPoP actually defends against: a stolen access token signed with the wrong key, a tampered htu, and a replayed proof.

Section A · the verification primitive (pedagogical)

This section generates an ECDSA P-256 keypair in your browser via SubtleCrypto.generateKey, builds a proof, and posts it to /api/v1/dpop-verify. The "proof claims" inputs and the "server expects" inputs are deliberately separate so you can mismatch them and watch a specific check fail — start with both sets equal for the happy path, then change one to see the verdict flip.

Step 1 — generate a keypair

The private key never leaves the browser — generated with extractable: false, so even a same-origin script (think: injected XSS) cannot read it back out via exportKey. Only WebCrypto itself, via subtle.sign, ever touches the private half. The public key is exported as a JWK and embedded in the proof header so the server can verify the signature and compute the RFC 7638 thumbprint.

no key yet

Step 2 — choose what the proof claims

These values go into the htm and htu claims of the signed proof. In a real client they match the actual request the client is about to make. Here you can set them independently from what the server expects (Step 3) so you can deliberately mismatch and watch a specific check fail.

Step 3 — tell the server what to expect

These are the expected_htm and expected_htu values sent in the verify request body. A production resource server would derive them from its own request line — here they are explicit so you can simulate the protected endpoint independently from the proof's claims. Mismatch either to see the htm or htu check fail.

Step 4 — sign and submit

The page builds base64url(header).base64url(payload) from the Step 2 claims, signs it with the private key, appends the base64url'd signature, and POSTs to the verify endpoint with the proof in the DPoP header and the Step 3 expected values in the body. The server tells you which checks passed or failed.

Current proof

(generate a key first)

Server verdict

(no result yet)
Section B · real DPoP deployment

The verification primitive above is correct but self-referential: the access token's cnf.jkt was set by the same page that generated the proof, so the binding check has nothing independent to compare against. Below is the honest deployment shape — the server issues the access token after a simulated login, the client signs DPoP proofs with the private key it holds, and the binding (cnf.jkt from server-side, JWK thumbprint from client-side) is the actual check RFC 9449 §6 mandates.

The same key generated in Step 1 is reused here. If you haven't generated one yet, scroll up and do that first.

Step B1 — Simulate login

POSTs the page's public JWK to /api/v1/login. The server computes the RFC 7638 thumbprint, mints a 10-minute HS256 session JWT with cnf.jkt set to that thumbprint, and returns it. The page stores it in sessionStorage for the rest of the flow.

no session
(no session yet — generate a keypair, then click Simulate login)

Step B2 — Call the protected endpoint

POSTs to /api/v1/dpop-verify with Authorization: DPoP <access_token> and a fresh DPoP proof bound to that token via ath. The server runs every RFC 9449 §4.3 check; in token-bound mode cnf.jkt and ath are both meaningful.

Attack scenarios

Three failure modes DPoP defends against. Each button runs the attack against the simulated-login session and shows you the per-check verdict — the failure is always loud.

Section B verdict

(no result yet)

What this is not

DPoP isDPoP is not
A per-request signature that proves the caller holds the private key the access token was bound to. A replacement for token rotation. Short access-token lifetimes still matter — DPoP raises the bar but a stolen key still wins for the key's lifetime.
Application-layer, deployable today on any TLS endpoint, by both the OAuth server (issues cnf.jkt tokens) and the resource server (verifies proofs). mTLS. mTLS binds at the transport layer and proves the TCP connection terminates at a keyholder; DPoP binds at the message layer and proves this specific HTTP request came from a keyholder. They solve overlapping but different problems — mTLS is harder to deploy, DPoP survives proxies and load balancers.
Retroactively safe to add to an existing API: a server can accept both DPoP-bound and plain bearer tokens during a migration window. A guarantee on day one. Both client SDKs and resource servers need to ship support; until your weakest client speaks DPoP, you still have bearer tokens in flight somewhere.
Defence against token theft from disk, browser, npm packages, CI cache, log files, and middlebox-decrypted TLS — all the places a bearer token routinely leaks. Defence against a fully-compromised client process. If the attacker can read the private key (extractable JWK in localStorage, leaked OS keychain, etc.), they can mint proofs at will. Non-extractable WebCrypto keys raise the cost; they don't eliminate it.
Defence against jti replay (the same proof submitted twice gets rejected on the second submission). Defence against the resource server itself going rogue. Like every server-validated mechanism, DPoP trusts the resource server to honour the check. It's a layer, not a panacea.
UX tradeoff: every protected request now needs a fresh proof, which means every API call from the browser now hits SubtleCrypto.sign. ES256 on a modern laptop is ~1-2ms — invisible — but the operation is async and the client SDK has to thread the proof generation into every call. Authentication libraries that hide this (e.g. oauth4webapi, jose's DPoP helpers, the AWS SDK's cognito-identity) make adoption realistic. Hand-rolled fetch wrappers do not.

Try to bypass

Open a terminal and try these. None of them are subtle — DPoP's design goal is for the failure modes to be loud.

No DPoP header, no body — fails on missing header:

curl -i -X POST http://localhost:5050/api/v1/dpop-verify

A garbage proof — fails at structure:

curl -i -X POST -H "Content-Type: application/json" \
  -H "DPoP: not.a.real.proof" \
  -d '{"expected_htm":"POST","expected_htu":"https://api.example.com/x"}' \
  http://localhost:5050/api/v1/dpop-verify

A proof signed for the wrong URL — fails at htu:

// Above, change "Server expects: htu" to a different value than
// "Proof claim: htu", then sign & submit. The signature check still
// passes (the proof is honestly signed), but the htu check fails
// because the proof's htu doesn't match what the server is protecting.

A proof with tampered claims — fails at signature:

// Above, click "Tamper with payload after signing". The page swaps
// the htu inside the payload AFTER signing, so the bytes the server
// hashes no longer match the signed bytes. The signature check fails;
// every other check is correctly evaluated against the tampered htu.
DPoP doesn't make scraping harder — that's not its job. It makes token theft harder. An attacker who has lifted your access token off a leaked log line, an over-permissioned npm package, or a compromised CI cache now has to also exfiltrate the private key from the browser or device that minted it. That second step is much, much harder than the first — especially with extractable: false WebCrypto keys, where the private key never serialises into JS-visible memory.

Need this designed into your API?

We help product and security teams roll out DPoP, mTLS, and JWT proof-of-possession across authenticated APIs — including the migration window where DPoP-bound and plain bearer tokens have to co-exist without breaking older clients.