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:
- An npm or PyPI package exfiltrates tokens from disk. The Polyfill.io and event-stream incidents shipped credential-stealers to millions of dev machines. CI/CD pipelines that cache bearer tokens are the highest-value lateral-movement target on a build server.
- A browser extension reads the
Authorizationheader. The "Great Suspender" supply-chain attack used legitimate extension permissions to scrape sessions; the same shape applies to any browser-side bearer token a SaaS UI uses to call its own API. - Network logging. An access token in a request that crosses a proxy, a reverse-engineered TLS terminator, or an unredacted error log is now compromised. The OWASP API Security 2023 top-ten ranks Broken Authentication (API2:2023) at the top precisely because bearer tokens leak.
- AI agents proxying your API. An agent that needs to call a third-party API on the user's behalf has to handle the token. The number of components that touch it has just increased, and so has the surface area for a leak.
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
}
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.
Step 1 — generate a keypair
Step 2 — choose what the proof claims
Step 3 — tell the server what to expect
Step 4 — sign and submit
Current proof
(generate a key first)
Server verdict
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.
Step B1 — Simulate login
Step B2 — Call the protected endpoint
Attack scenarios
Section B verdict
What this is not
| DPoP is | DPoP 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. |
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.
curl -i -X POST http://localhost:5050/api/v1/dpop-verify
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
// 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.
// 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.
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.