Sender-Constrained Tokens with DPoP
Bind an access token to a key only the client holds, so a stolen token is useless to anyone else.
The defining weakness of a bearer token is in the name: whoever bears it is the user. Copy it from a log, a proxy, or a compromised cache and it works. DPoP (Demonstrating Proof-of-Possession, RFC 9449) removes that property.
The idea
The client generates a key pair and keeps the private key. Every request carries two things:
- The access token, sent as
Authorization: DPoP <token>— note the scheme isDPoP, notBearer. - A DPoP proof: a short JWT in the
DPoPheader, signed with the private key, covering the HTTP method and URL.
The token itself carries a cnf (confirmation) claim holding the JWK thumbprint of that public key. The resource server checks that the proof verifies against the key whose thumbprint is in the token. A thief with only the token cannot produce a valid proof.
What is inside a proof
| Field | Meaning |
|---|---|
typ (header) | must be dpop+jwt |
jwk (header) | the public key, so the server can verify |
htm | HTTP method — binds the proof to POST, not GET |
htu | the URL, minus query and fragment |
iat | issued at — must be recent |
jti | unique id, for replay detection |
ath | hash of the access token (required at resource servers) |
nonce | server-supplied, when the server demands one |
What it stops, and what it does not
Stops: token replay from anywhere the private key is not. A token leaked into a log, an APM trace, a referrer, or a shared cache is inert.
Does not stop: an attacker who is executing code in the client itself. An XSS on your page can call crypto.subtle.sign just as your code can — unless the key is non-extractable, in which case the attacker can use it while the page is open but cannot exfiltrate it. That is the same bounded-versus-portable distinction as memory-only token storage, and it is a genuine improvement.
The nonce
Servers can require a nonce (returned in DPoP-Nonce, with a 401 and error="use_dpop_nonce"). It pins the proof to a server-chosen value, so proofs cannot be pre-generated. Clients must handle that 401 by retrying once with the supplied nonce — a detail almost every first implementation misses.
When to reach for it
High-value APIs: payments, healthcare, admin planes, anything where a leaked token is a reportable incident. For a typical CRUD API, short-lived bearer tokens plus refresh rotation are the proportionate answer.
Example
POST /api/payments HTTP/1.1
Host: dfg.com
Authorization: DPoP eyJhbGciOiJSUzI1NiIsInR5cCI6ImF0K2p3dCJ9...
DPoP: eyJ0eXAiOiJkcG9wK2p3dCIsImFsZyI6IkVTMjU2IiwiandrIjp7Imt0eSI6IkVDIi4uLn19
.eyJodG0iOiJQT1NUIiwiaHR1IjoiaHR0cHM6Ly9kZmcuY29tL2FwaS9wYXltZW50cyIsI
mlhdCI6MTc1NDMxMjQwMCwianRpIjoiYzFmOSIsImF0aCI6IlNoYTI1Nkhhc2gifQ
.MEUCIQ...
# The access token's own payload carries the binding:
# { "sub": "42", "cnf": { "jkt": "0ZcOCORZNYy-DWpqq30jZyJGHTN0d2HglBV3uiguA4I" } }
# cnf.jkt = SHA-256 thumbprint of the public key in the proof's jwk header
#
# Steal the token alone → you cannot produce the proof → 401.When to use it
- A payments API requires DPoP so an access token captured in a partner's request log cannot be replayed from another machine.
- A browser client generates a non-extractable ECDSA key in IndexedDB, so an XSS can use the key while the tab is open but can never export it.
- An open-banking implementation adopts DPoP because the regulator requires proof-of-possession and mTLS is impractical for the mobile channel.
More examples
Generating a non-extractable key and signing proofs (browser)
Exporting the key as JWK returns only the public coordinates because the pair was created non-extractable — attempting to export the private half throws.
// extractable: false is the entire security benefit in a browser. The key can
// be USED by anything on the page, but never read out and carried away.
async function createDpopKey() {
const pair = await crypto.subtle.generateKey(
{ name: 'ECDSA', namedCurve: 'P-256' },
false, // ← non-extractable
['sign', 'verify'],
);
// Persist the CryptoKey handle itself; IndexedDB can store it without the
// raw material ever being exposed to JavaScript.
await idbPut('dpop', 'key', pair);
return pair;
}
const b64url = (buf) =>
btoa(String.fromCharCode(...new Uint8Array(buf)))
.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
async function dpopProof({ method, url, accessToken, nonce }) {
const pair = await idbGet('dpop', 'key');
const jwk = await crypto.subtle.exportKey('jwk', pair.publicKey); // public only
const header = {
typ: 'dpop+jwt',
alg: 'ES256',
jwk: { kty: jwk.kty, crv: jwk.crv, x: jwk.x, y: jwk.y }, // no 'd' — private
};
const payload = {
htm: method.toUpperCase(),
htu: url.split('?')[0].split('#')[0], // no query, no fragment
iat: Math.floor(Date.now() / 1000),
jti: crypto.randomUUID(),
...(nonce ? { nonce } : {}),
// ath binds the proof to THIS access token — required at resource servers
...(accessToken
? { ath: b64url(await crypto.subtle.digest('SHA-256',
new TextEncoder().encode(accessToken))) }
: {}),
};
const signingInput =
`${b64url(new TextEncoder().encode(JSON.stringify(header)))}.` +
`${b64url(new TextEncoder().encode(JSON.stringify(payload)))}`;
const sig = await crypto.subtle.sign(
{ name: 'ECDSA', hash: 'SHA-256' },
pair.privateKey,
new TextEncoder().encode(signingInput),
);
return `${signingInput}.${b64url(sig)}`;
}A client that handles the nonce challenge
Each retry regenerates the proof rather than reusing it — the jti must be unique or the server's replay cache rejects the second attempt.
let dpopNonce = null; // servers rotate this; keep the latest
export async function dpopFetch(url, options = {}) {
const method = (options.method ?? 'GET').toUpperCase();
const token = await getAccessToken();
const send = async () => fetch(url, {
...options,
headers: {
...options.headers,
Authorization: `DPoP ${token}`, // NOT 'Bearer'
DPoP: await dpopProof({ method, url, accessToken: token, nonce: dpopNonce }),
},
});
let res = await send();
// The server may demand a nonce it chooses. Retry ONCE with it.
if (res.status === 401) {
const supplied = res.headers.get('DPoP-Nonce');
const wwwAuth = res.headers.get('WWW-Authenticate') ?? '';
if (supplied && wwwAuth.includes('use_dpop_nonce')) {
dpopNonce = supplied;
res = await send(); // fresh proof, fresh jti
}
}
// Servers rotate the nonce proactively — always take the newest one.
const rotated = res.headers.get('DPoP-Nonce');
if (rotated) dpopNonce = rotated;
return res;
}
// Missing this retry is the classic first-implementation bug: everything works
// against a server that does not require nonces, then breaks in production.Server-side verification, every check
Using SET NX with a TTL for the jti makes replay detection a single atomic operation, and the cache can never outgrow the proof acceptance window.
import { jwtVerify, calculateJwkThumbprint, importJWK, decodeProtectedHeader } from 'jose';
import { createHash } from 'crypto';
const PROOF_MAX_AGE_S = 60;
export async function verifyDpop(req, accessTokenClaims, rawAccessToken) {
const proof = req.get('dpop');
if (!proof) throw new AuthError('use_dpop_nonce', 'DPoP proof required');
// 1. The header must declare the right type and carry the public key.
const header = decodeProtectedHeader(proof);
if (header.typ !== 'dpop+jwt') throw new AuthError('invalid_dpop_proof', 'bad typ');
if (!header.jwk || header.jwk.d) throw new AuthError('invalid_dpop_proof', 'bad jwk');
if (!['ES256', 'RS256', 'PS256'].includes(header.alg)) {
throw new AuthError('invalid_dpop_proof', 'unsupported alg');
}
// 2. The proof must verify against the key it embeds.
const key = await importJWK(header.jwk, header.alg);
const { payload } = await jwtVerify(proof, key, { clockTolerance: 5 });
// 3. Method and URL must match THIS request.
const expectedUrl = `${req.protocol}://${req.get('host')}${req.path}`;
if (payload.htm !== req.method) throw new AuthError('invalid_dpop_proof', 'htm mismatch');
if (payload.htu !== expectedUrl) throw new AuthError('invalid_dpop_proof', 'htu mismatch');
// 4. Freshness.
const age = Math.floor(Date.now() / 1000) - Number(payload.iat ?? 0);
if (!payload.iat || age > PROOF_MAX_AGE_S || age < -5) {
throw new AuthError('invalid_dpop_proof', 'stale proof');
}
// 5. Replay: jti must be unseen. TTL matches the acceptance window.
if (!payload.jti) throw new AuthError('invalid_dpop_proof', 'missing jti');
const fresh = await redis.set(`dpop:${payload.jti}`, '1', 'EX', PROOF_MAX_AGE_S, 'NX');
if (!fresh) throw new AuthError('invalid_dpop_proof', 'proof replayed');
// 6. ath binds the proof to this specific access token.
const ath = createHash('sha256').update(rawAccessToken).digest('base64url');
if (payload.ath !== ath) throw new AuthError('invalid_dpop_proof', 'ath mismatch');
// 7. THE BINDING: the token's cnf.jkt must equal this key's thumbprint.
const thumbprint = await calculateJwkThumbprint(header.jwk, 'sha256');
if (accessTokenClaims.cnf?.jkt !== thumbprint) {
throw new AuthError('invalid_token', 'token is not bound to this key');
}
// 8. Nonce, if this server requires one.
if (REQUIRE_NONCE && !(await nonceIsValid(payload.nonce))) {
throw new AuthError('use_dpop_nonce', 'nonce required', { nonce: issueNonce() });
}
return true;
}
// Step 7 is the one that matters. Skip it and you have an elaborate ritual that
// verifies a proof against a key the attacker also chose — no binding at all.
Discussion