Ever had this happen? Someone grabs your access token from the network tab, a log, or a leaked header — pastes it straight into Postman — and your API answers them like nothing's wrong. If that scenario makes you nervous, this post is for you.
A normal access token is a bearer token — like cash. Whoever holds it can spend it. Steal it from a log, an XSS bug, or a leaky proxy, and the thief is in.
DPoP ties the token to a private key only your client has. Now a stolen token alone is worthless — the attacker would also need the key, and the key never leaves the client.
One thing to be clear about up front: DPoP doesn't stop people from calling your API (that's rate limiting / WAF territory). It makes a leaked token un-replayable. That's the whole job — and for high-value or regulated APIs, it's a big one.
Spec: RFC 9449.
A normal OAuth 2.0 access token is a bearer token. The name is the whole story: whoever bears it can use it. The server does not check who is presenting the token — only that the token is valid.
That means a token is exactly like a $100 bill. If it falls out of your pocket and a stranger picks it up, it spends just as well for them as it did for you.
In an API, tokens "fall out of your pocket" more often than you'd think:
- An XSS bug reads the token out of
localStorage. - A token ends up in a log line, an error report, or an analytics payload.
- A misconfigured proxy or a leaky third-party SDK forwards the
Authorizationheader somewhere it shouldn't go. - A token sits in browser history or a shared device.
Once an attacker has the token, they replay it from their own machine and your server happily serves them, because the token is valid.
DPoP fixes this specific problem. It does not stop someone from hitting your endpoints (that's what rate limiting, WAFs, and authn at the edge are for). What it does is make a stolen token worthless to anyone except the client that originally requested it. So if you adopted it "to stop hackers calling your APIs," the precise framing is: a leaked token can no longer be replayed by an attacker. That's a big deal for high-value or regulated APIs — anything where forged calls at scale would be expensive or dangerous.
DPoP — Demonstrating Proof of Possession — is standardized in RFC 9449.
The core idea in one sentence
The client proves, on every single request, that it holds the private key that the token was bound to at the moment it was issued.
A stolen token without the matching private key is useless. To replay it, an attacker would need the token and the private key — and the private key never leaves the client (and, done right, can't be exported at all).
How it works: the moving parts
DPoP introduces three things:
-
A client-held key pair. The client generates an asymmetric key pair (commonly
ES256). The private key stays on the client; the public key gets embedded in proofs. -
A DPoP proof JWT. A short-lived, single-use JWT, signed with the private key, sent in a
DPoPheader on each request. It says "this request, this method, this URL, right now." -
A binding on the token. When the token is issued, the authorization server records a thumbprint of the client's public key inside the token (the
cnf.jktclaim). Every later request must present a proof signed by the matching private key.
What a DPoP proof actually looks like
The header identifies it as a DPoP proof and carries the public key:
{
"typ": "dpop+jwt",
"alg": "ES256",
"jwk": {
"kty": "EC",
"crv": "P-256",
"x": "l8tFrhx-34tV3hRICRDY9zCkDlpBhF42UQUfWVAWBFs",
"y": "9VE4jf_Ok_o64zbTTlcuNJajHmt6v9TDVrU0CdvGRDA"
}
}
The payload describes the request it's bound to:
{
"jti": "f1d9c0a2-7b3e-4c1a-9e2f-1c2b3a4d5e6f",
"htm": "POST",
"htu": "https://api.securestrip.example/v1/verify",
"iat": 1718000000,
"ath": "fUHyO2r2Z3DZ53EsNrWBb0xWXoaNy59IiKCAqksmQEo"
}
| Claim | Meaning |
|---|---|
jti |
Unique ID for this proof — used for replay detection |
htm |
HTTP method this proof is valid for |
htu |
HTTP target URI (scheme + host + path, no query/fragment) |
iat |
Issued-at — proofs are only accepted within a small time window |
ath |
base64url(SHA-256(access_token)) — ties the proof to a specific token (required when calling a resource server) |
nonce |
A server-supplied value, when the server demands one (more below) |
The token binding (cnf.jkt)
When the authorization server issues a DPoP-bound access token, it computes the JWK SHA-256 thumbprint of the client's public key and stamps it into the token:
{
"sub": "device-7741",
"iss": "https://auth.securestrip.example",
"aud": "https://api.securestrip.example",
"exp": 1718003600,
"cnf": {
"jkt": "0ZcOCORZNYy-DWpqq30jZyJGHTN0d2HglBV3uiguA4I"
}
}
cnf = "confirmation", jkt = "JWK thumbprint." This is the anchor. The resource server later recomputes the thumbprint from the proof's embedded jwk and checks it equals cnf.jkt. Match = the caller holds the right key. No match = rejected.
For opaque access tokens, the AS stores the binding server-side and returns the
jktvia token introspection instead of putting it in the token.
One more change: the scheme is DPoP, not Bearer
A DPoP-bound token is presented like this:
GET /v1/verify HTTP/1.1
Host: api.securestrip.example
Authorization: DPoP eyJhbGciOiJSUzI1NiIsInR5cCI6... <-- the access token
DPoP: eyJ0eXAiOiJkcG9wK2p3dCIsImFsZ... <-- the fresh proof JWT
Two headers: the token (Authorization: DPoP ...) and a freshly minted proof (DPoP: ...).
The full flow, end to end
┌────────┐ 1. token request + DPoP proof (key A) ┌──────────────┐
│ Client │ ───────────────────────────────────────────────────▶ │ Authz Server │
│ (key │ │ │
│ pair)│ ◀─────────────────────────────────────────────────── │ │
└────────┘ 2. access token with cnf.jkt = thumbprint(key A) └──────────────┘
│
│ 3. resource request
│ Authorization: DPoP <token>
│ DPoP: <proof signed by key A, with ath = hash(token)>
▼
┌──────────────────┐
│ Resource Server │ 4. verify proof signature, htm/htu, iat,
│ │ jti not seen, thumbprint(jwk) == token.cnf.jkt,
│ │ ath == hash(token) → serve or reject
└──────────────────┘
The magic is step 4's thumbprint check: it links the live request back to the key the token was issued for.
The "how": code
I'll use Node and jose, but the concepts map to any language.
Client — create and reuse a key pair
import { generateKeyPair, exportJWK } from 'jose';
// Generate ONCE per session and keep it for the session's lifetime.
const { publicKey, privateKey } = await generateKeyPair('ES256');
const publicJwk = await exportJWK(publicKey);
In the browser, generate the key with the WebCrypto API and mark it
extractable: false. The private key then physically cannot be read out by JavaScript — even an XSS payload can ask it to sign, but can't steal the key itself. That's the single most important hardening step on the client.
Client — build a proof for each request
import { SignJWT } from 'jose';
import { createHash, randomUUID } from 'node:crypto';
const base64url = (buf) =>
buf.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
const ath = (accessToken) =>
base64url(createHash('sha256').update(accessToken).digest());
async function createDpopProof({ htm, htu, accessToken, nonce }) {
const payload = { htm, htu, jti: randomUUID() };
if (accessToken) payload.ath = ath(accessToken); // required at the resource server
if (nonce) payload.nonce = nonce; // when the server demands one
return new SignJWT(payload)
.setProtectedHeader({ typ: 'dpop+jwt', alg: 'ES256', jwk: publicJwk })
.setIssuedAt()
.sign(privateKey);
}
Client — call the API
const htu = 'https://api.securestrip.example/v1/verify'; // no query string!
const proof = await createDpopProof({ htm: 'POST', htu, accessToken });
const res = await fetch(htu, {
method: 'POST',
headers: {
Authorization: `DPoP ${accessToken}`,
DPoP: proof,
'Content-Type': 'application/json',
},
body: JSON.stringify({ stripId: '...' }),
});
Resource server — verify the proof
import { jwtVerify, importJWK, calculateJwkThumbprint, decodeProtectedHeader } from 'jose';
import { createHash } from 'node:crypto';
// In production use Redis with a TTL, not an in-memory Set (see "Cases" below).
const seenJti = new Set();
const base64url = (buf) =>
buf.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
const ath = (token) => base64url(createHash('sha256').update(token).digest());
async function verifyDpop(req, tokenClaims) {
const proofJwt = req.headers['dpop'];
if (!proofJwt) return reject('missing_dpop_proof');
// 1. Read the embedded public key from the header and verify the signature with it.
const { jwk } = decodeProtectedHeader(proofJwt);
const key = await importJWK(jwk, jwk.alg ?? 'ES256');
const { payload } = await jwtVerify(proofJwt, key, { typ: 'dpop+jwt' });
// 2. Bind the proof to THIS request.
if (payload.htm !== req.method) return reject('htm_mismatch');
const htu = `${req.protocol}://${req.get('host')}${req.path}`; // no query string
if (payload.htu !== htu) return reject('htu_mismatch');
// 3. Freshness — small window; widen only if you can't sync clocks.
const now = Math.floor(Date.now() / 1000);
if (Math.abs(now - payload.iat) > 30) return reject('proof_expired');
// 4. Replay protection — each jti is single use.
if (seenJti.has(payload.jti)) return reject('replay_detected');
seenJti.add(payload.jti);
// 5. THE binding check — does the caller hold the key the token was issued for?
const thumbprint = await calculateJwkThumbprint(jwk);
if (thumbprint !== tokenClaims.cnf?.jkt) return reject('token_not_bound_to_key');
// 6. Tie proof to THIS token.
const presentedToken = (req.headers['authorization'] || '').split(' ')[1];
if (payload.ath !== ath(presentedToken)) return reject('ath_mismatch');
return true;
}
That's the entire mechanism. Six checks, one of which (step 5) is the actual sender-constraining magic; the rest stop tampering and replay.
The "all the cases" part: what production actually throws at you
A toy implementation handles the happy path. A real one has to handle these.
Case 1 — Binding at the token endpoint (issuance)
The client sends a DPoP proof to the token endpoint (htu = the token URL). The AS reads the jwk, computes the thumbprint, and stamps cnf.jkt into the issued access token. No proof here ⇒ no binding ⇒ you've gained nothing. This is the root of trust.
Case 2 — Calling a protected resource
Covered above. Key point: the proof here must include ath (the hash of the token). Without it, an attacker who captured a valid proof for endpoint X could pair it with a different stolen token. ath welds proof and token together.
Case 3 — Refresh tokens must be bound too
For public clients (SPAs, mobile, native), the refresh token is also sender-constrained. The client sends a proof (with the same key) on every refresh call. This matters enormously: a stolen refresh token is a long-lived skeleton key. DPoP-binding it means a leaked refresh token can't be redeemed by an attacker either.
Case 4 — Server-supplied nonce (the strongest replay defense)
Relying only on iat + jti means an attacker who grabs a fresh proof has a few-second replay window. To close it, the server can demand a nonce it controls.
Server, when it wants a nonce:
HTTP/1.1 401 Unauthorized
WWW-Authenticate: DPoP error="use_dpop_nonce", error_description="nonce required"
DPoP-Nonce: eyJ7S_zG.bC8...
The client reads DPoP-Nonce, rebuilds the proof with that nonce claim, and retries. Because the server issued the nonce (typically short-lived and single-use), proofs can't be pre-generated or replayed beyond it.
Client retry logic:
let res = await callApi();
if (res.status === 401 && wantsNonce(res)) {
const nonce = res.headers.get('DPoP-Nonce');
res = await callApi({ nonce }); // one retry with the fresh nonce
}
Build this retry loop from day one — many DPoP servers rotate nonces and will hand you a new one on responses, expecting it on the next call.
Case 5 — Replay protection at scale (jti store)
The seenJti Set above leaks memory and doesn't work across instances. In production:
- Store each
jtiin Redis with a TTL equal to your acceptediatwindow (e.g. 60s). After that the proof is too old to replay anyway, so you can forget thejti. - Behind a load balancer, all API instances must share that store — otherwise a proof replayed against a different instance won't be caught.
const ok = await redis.set(`dpop:jti:${payload.jti}`, '1', 'EX', 60, 'NX');
if (!ok) return reject('replay_detected'); // NX failed ⇒ already seen
Case 6 — Clock skew and the iat window
iat is checked against server time. Mobile devices and browsers drift. Options, best to worst:
- Require a nonce (Case 4) — then exact clock sync barely matters.
- Allow a small symmetric skew (±30–60s).
- Don't be tempted to widen the window to minutes — that's a bigger replay surface.
Case 7 — Key rotation mid-session
If the client rotates its key, the old token's cnf.jkt no longer matches. The client must obtain a new token bound to the new key (e.g. via a DPoP-bound refresh). Plan for "rotate key ⇒ re-bind tokens," and don't rotate more often than your token lifetime needs.
Case 8 — Multiple resource servers
Each resource server validates independently. htu differs per server, and each recomputes the thumbprint against the token's cnf.jkt. The same key pair works across all of them — the client just mints a fresh proof per request with the correct htm/htu.
Case 9 — Opaque tokens
No cnf claim to read locally. The resource server calls the AS introspection endpoint, which returns the bound jkt. Same thumbprint comparison, the binding just arrives via introspection instead of a JWT claim.
Case 10 — Proxies, TLS termination, and htu mismatches
The #1 real-world bug: your app sees http://internal-host/path while the client signed https://api.public.example/path. Then htu never matches and every request 401s.
- Set
app.set('trust proxy', true)(Express) soreq.protocol/req.get('host')reflect the public URL. - Normalize
htuconsistently on both sides: scheme + authority + path, lowercased scheme/host, no default port, no query, no fragment.
Case 11 — Error responses your client must understand
| Situation | Status | error |
|---|---|---|
Proof malformed / signature bad / htm/htu/iat wrong |
401 | invalid_dpop_proof |
| Server requires a nonce | 401 |
use_dpop_nonce (+ DPoP-Nonce header) |
| Token not bound to presented key | 401 | invalid_token |
Your client should retry exactly once on use_dpop_nonce and surface the rest as auth failures.
The "when": should you actually use DPoP?
Reach for DPoP when:
- You have public clients — SPAs, mobile apps, native apps — that can't keep a secret and where mTLS is impractical. This is the headline use case.
- Your API is high-value or regulated — payments, health, identity, anti-counterfeit verification — where a replayed token causes real damage. (If a leaked token would let someone forge "this product is genuine" calls at scale, DPoP is squarely the right tool.)
- You want defense in depth alongside short token lifetimes and good edge controls.
DPoP vs mTLS (RFC 8705): both sender-constrain tokens. mTLS does it at the transport layer with client certificates and needs a PKI — excellent for confidential server-to-server clients, painful for browsers and mobile. DPoP does it at the application layer with no PKI — built precisely for public clients. Many architectures use mTLS between back-end services and DPoP for user-facing apps.
You probably don't need it when:
- It's pure server-to-server traffic where mTLS is already in place.
- The endpoints are low-risk and a leaked token is cheap to revoke.
- You can't yet afford the operational pieces below — in which case ship short token lifetimes + rotation first, then add DPoP.
What DPoP does not solve (set expectations honestly)
- It is not a WAF or rate limiter. It doesn't stop traffic, block IPs, or throttle abuse. It makes stolen tokens unusable — that's the entire scope.
- It doesn't save you from a fully compromised client. If an attacker is running inside your page/app with access to the signing key, they can sign valid proofs while they're there. Non-extractable keys limit them to while present (they can't exfiltrate the key for later/offline use), but they don't make a compromised client safe. Fix the XSS too.
- It binds to a key, not to a TLS channel. That's by design — it's why it works for public clients — but it means transport-level guarantees still matter.
-
It adds moving parts: key lifecycle, a shared replay store, nonce handling, clock tolerance, and
htunormalization behind proxies. Budget for those.
A pragmatic rollout checklist
- Generate a non-extractable key pair per session (WebCrypto in browsers).
- Bind tokens at the token endpoint and bind refresh tokens for public clients.
- Send
Authorization: DPoP <token>+ a freshDPoP:proof on every call; includeath. - On the server, verify: signature,
typ,htm,htu,iatwindow,jti(shared store),cnf.jktthumbprint,ath. - Implement the
use_dpop_nonce401 retry on the client and issue/rotate nonces on the server. - Use Redis (or equivalent) for
jti/nonce state, shared across all API instances. - Set
trust proxyand normalizehtuso it survives TLS termination. - Keep token lifetimes short — DPoP is a complement to rotation, not a replacement.
Wrapping up
Bearer tokens trust possession. DPoP upgrades that to proof of possession: every request carries a fresh, signed challenge proving the caller still holds the private key the token was minted for. Steal the token, and without the key it's a coupon you can't redeem.
It won't keep attackers from knocking on your door — but it makes sure that when they do, the keys they picked up off the floor don't open anything.
Spec: RFC 9449 — OAuth 2.0 Demonstrating Proof of Possession (DPoP). Thumbprints: RFC 7638.
Top comments (0)