Short answer. DPoP (Demonstrating Proof of Possession, RFC 9449, September 2023) binds an access token to a key pair the client generates. The authorization server records the public key's thumbprint in the token (cnf.jkt) and answers with token_type: DPoP. On every request the client sends the token with the DPoP scheme plus a DPoP header: a short JWT signed with the private key, naming the HTTP method (htm), the URL (htu), a unique jti, the time (iat) and the hash of the access token (ath). The resource server verifies the proof, rejects reused jti values and old proofs, and checks that the proof's key is the one the token is bound to. Use it when access or refresh tokens live somewhere they can be copied from, such as browsers and mobile apps, or when a profile such as FAPI 2.0 requires sender-constrained tokens.
What problem DPoP solves
An OAuth bearer token (RFC 6750) is like cash: whoever presents it gets access. Tokens leak through logs, browser storage, proxies, crash reports and compromised dependencies, and a leaked bearer token works from anywhere until it expires. Sender-constrained tokens add a second factor that does not travel with the token. OAuth has two standard ways to do it: mutual TLS (RFC 8705), which binds the token to a client certificate, and DPoP, which binds it to a key used at the application layer. DPoP needs no TLS client certificates, works from a browser and through TLS-terminating proxies, and costs one signature per request.
What DPoP does not do: it does not sign the request body or headers, only the method and URL (§4.2), and it does not help if an attacker can run code inside the client, because that code can ask the client's key to sign proofs (§11.4). For signing whole requests, see HTTP Message Signatures.
The flow
- The client generates a key pair, ideally non-extractable, and keeps it for the lifetime of its tokens.
- Token request. The client sends its normal token request with a
DPoPheader containing a proof forPOSTand the token endpoint URL. If the authorization server wants a nonce, it answers400withuse_dpop_nonceand aDPoP-Nonceheader, and the client retries with that nonce in the proof. - Token response.
token_typeisDPoP, notBearer. A JWT access token carries"cnf": {"jkt": "<SHA-256 JWK thumbprint>"}(§6.1, RFC 7638); an opaque token exposes the same value through introspection (§6.2). Refresh tokens issued to public clients are bound to the key too (§5). - Resource request.
Authorization: DPoP <access token>plus a new proof for this method and URL, now includingath, the base64url SHA-256 hash of the access token. - The resource server checks the proof and the binding, and may itself demand a nonce with
401andWWW-Authenticate: DPoP error="use_dpop_nonce"(§9).
The proof carries these members (§4.2):
| Where | Member | Meaning |
|---|---|---|
| header | typ | dpop+jwt, so a proof can never be confused with another JWT |
| header | alg | asymmetric only; never none or HS* |
| header | jwk | the client's public key; never a private key |
| payload | jti | unique per proof: at least 96 random bits, or a UUID v4 |
| payload | htm, htu | HTTP method, and target URI without query and fragment |
| payload | iat | creation time |
| payload | ath | hash of the access token, required when one is presented |
| payload | nonce | the server's latest DPoP-Nonce, when it sent one |
Client: making proofs
import { randomUUID, createHash } from "node:crypto";
import { SignJWT, exportJWK, generateKeyPair } from "jose";
// One key pair per client instance. The private key is a non-extractable CryptoKey:
// code that can call sign() can make proofs, but cannot copy the key elsewhere.
const { privateKey, publicKey } = await generateKeyPair("ES256");
const jwk = await exportJWK(publicKey);
export const ath = (accessToken) => createHash("sha256").update(accessToken, "ascii").digest("base64url");
/** A DPoP proof for one request (RFC 9449 §4.2). */
export function dpopProof({ method, url, accessToken, nonce }) {
const target = new URL(url);
return new SignJWT({
jti: randomUUID(),
htm: method,
htu: target.origin + target.pathname, // without query and fragment
...(accessToken && { ath: ath(accessToken) }),
...(nonce && { nonce }),
})
.setProtectedHeader({ typ: "dpop+jwt", alg: "ES256", jwk })
.setIssuedAt()
.sign(privateKey);
}
/** Calls fetch with a fresh proof; retries once when the server asks for a nonce. */
export async function dpopFetch(url, { method = "GET", accessToken, headers = {}, body } = {}, state = {}) {
for (let attempt = 0; attempt < 2; attempt++) {
const proof = await dpopProof({ method, url, accessToken, nonce: state.nonce });
const res = await fetch(url, {
method,
body,
headers: { ...headers, DPoP: proof, ...(accessToken && { Authorization: `DPoP ${accessToken}` }) },
});
if (res.headers.has("dpop-nonce")) state.nonce = res.headers.get("dpop-nonce");
const challenge = res.headers.get("www-authenticate") ?? "";
const bodyText = res.status === 400 ? await res.clone().text() : "";
const askedForNonce = challenge.includes('error="use_dpop_nonce"') || bodyText.includes('"use_dpop_nonce"');
if (!askedForNonce || attempt === 1) return res;
}
}
In a browser the same approach works with the Web Crypto API: a key generated with extractable: false can be stored in IndexedDB and used to sign, but script cannot read its private half. That protects against theft of the key, not against a malicious script on the page asking it to sign.
Resource server: checking proofs
RFC 9449 §4.3 lists twelve checks, which "may be performed in any order", and §7 adds the token binding. The verifier below implements them for one request, with the step numbers in the comments:
import { createHash, randomBytes } from "node:crypto";
import { EmbeddedJWK, calculateJwkThumbprint, createRemoteJWKSet, jwtVerify } from "jose";
const PROOF_ALGS = ["ES256", "EdDSA", "PS256"]; // asymmetric only; never none or HS*
const MAX_PROOF_AGE = 60; // seconds
const SKEW = 5; // seconds a client clock may run ahead
export class DpopError extends Error {
constructor(error, description, headers = {}) {
super(description);
this.error = error; // invalid_token | invalid_dpop_proof | use_dpop_nonce
this.headers = headers;
}
}
const sha256 = (text) => createHash("sha256").update(text, "ascii").digest("base64url");
const normalizeHtu = (htu) => {
const u = new URL(htu); // lowercases scheme and host, drops default ports (RFC 3986 §6.2.2-6.2.3)
return u.origin + u.pathname;
};
/** Resource-server side of RFC 9449: checks §4.3 and §7 for one request. */
export function createDpopVerifier({ issuer, jwksUri, audience, publicOrigin, requireNonce }) {
const jwks = createRemoteJWKSet(new URL(jwksUri));
const seen = new Map(); // "htu jti" -> expiry, for replay detection (§11.1)
const nonce = randomBytes(16).toString("base64url"); // rotate it on a timer in production
return async function verify(req) {
// §7.1: a DPoP-bound access token arrives with the DPoP scheme, never as Bearer (§7.2).
const [scheme, accessToken, ...extra] = (req.headers.authorization ?? "").split(" ");
if (scheme !== "DPoP" || !accessToken || extra.length > 0) {
throw new DpopError("invalid_token", "DPoP authorization scheme required");
}
// Steps 1-2: exactly one DPoP header holding one JWT.
const proofs = req.headersDistinct.dpop ?? [];
if (proofs.length !== 1) throw new DpopError("invalid_dpop_proof", `${proofs.length} DPoP headers`);
// Steps 3-7 and 11: claims present, typ, asymmetric alg, signature with the embedded
// public key (EmbeddedJWK refuses private keys), and iat within the window.
let proof;
try {
proof = await jwtVerify(proofs[0], EmbeddedJWK, {
typ: "dpop+jwt",
algorithms: PROOF_ALGS,
requiredClaims: ["jti", "htm", "htu", "iat", "ath"],
maxTokenAge: MAX_PROOF_AGE,
clockTolerance: SKEW,
});
} catch (e) {
throw new DpopError("invalid_dpop_proof", e.message);
}
const { payload: p, protectedHeader } = proof;
// Steps 8-9: the proof was made for this method and this URI.
const target = new URL(req.url, publicOrigin);
if (p.htm !== req.method) throw new DpopError("invalid_dpop_proof", `htm ${p.htm} != ${req.method}`);
if (normalizeHtu(p.htu) !== target.origin + target.pathname) {
throw new DpopError("invalid_dpop_proof", `htu ${p.htu} != ${target.origin}${target.pathname}`);
}
// Step 10: the server's current nonce, if this server hands them out.
if (requireNonce && p.nonce !== nonce) {
throw new DpopError("use_dpop_nonce", "Resource server requires nonce in DPoP proof", { "DPoP-Nonce": nonce });
}
// Replay: each jti once per target URI while the proof could still be accepted.
const key = `${p.htu} ${p.jti}`;
const now = Date.now();
for (const [k, expiry] of seen) if (expiry < now) seen.delete(k);
if (seen.has(key)) throw new DpopError("invalid_dpop_proof", "DPoP proof already used");
seen.set(key, (p.iat + MAX_PROOF_AGE + SKEW) * 1000);
// The access token itself: issuer, audience, signature, expiry.
let token;
try {
({ payload: token } = await jwtVerify(accessToken, jwks, { issuer, audience, algorithms: ["RS256"] }));
} catch (e) {
throw new DpopError("invalid_token", e.message);
}
// Step 12: the proof belongs to this token, and the token is bound to the proof's key.
if (p.ath !== sha256(accessToken)) throw new DpopError("invalid_dpop_proof", "ath does not match the access token");
if (token.cnf?.jkt !== (await calculateJwkThumbprint(protectedHeader.jwk, "sha256"))) {
throw new DpopError("invalid_token", "Invalid DPoP key binding");
}
return token;
};
}
/** Turns a DpopError into the 401 response RFC 9449 §7.1 describes. */
export function challenge(err) {
const description = err.message.replaceAll('"', "'");
return {
status: 401,
headers: {
"WWW-Authenticate": `DPoP error="${err.error}", error_description="${description}", algs="${PROOF_ALGS.join(" ")}"`,
...err.headers,
},
};
}
Three details matter more than they look:
publicOriginis configuration, not theHostheader. Behind a load balancer the server sees an internal URL. Reconstructing the URL from request headers either fails every request or trusts a header the client controls.- The replay store is per target URI and only as long as the proof window. With several instances behind one endpoint the store must be shared, which RFC 9449 admits "may not always be feasible"; short-lived server nonces are the alternative.
- Reject a DPoP-bound token sent as
Bearer(§7.2). Otherwise the binding is optional for whoever stole the token.
Tested against Keycloak-issued tokens
The lab ran Keycloak 26.8.1 (quay.io/keycloak/keycloak:26.8.1, dev mode) with one confidential client, orders-cli, set to require DPoP-bound access tokens (the client attribute dpop.bound.access.tokens), and an audience mapper adding orders-api. A driver requested tokens with proofs from dpop-client.mjs, ran a resource server on port 4000 built on dpop-verify.mjs with nonces switched on, and then tried what an attacker with a copied token or a copied request would try:
dpop_signing_alg_values_supported: PS384 RS384 EdDSA ES384 ES256 RS256 ES512 PS256 PS512 RS512
token request, proof with htm GET: 400 {"error":"invalid_request","error_description":"DPoP HTTP method mismatch"}
token request without DPoP header: 400 {"error":"invalid_request","error_description":"DPoP proof is missing"}
token response: 200, token_type DPoP
access token aud ["orders-api","account"], cnf {"jkt":"jxtFYlJLzPaVIiNgZHu-vReD5s26NbuoroqLJ-H7A1k","kc-jkt-type":"DPoP"}
thumbprint of the client's DPoP key: jxtFYlJLzPaVIiNgZHu-vReD5s26NbuoroqLJ-H7A1k
first call, no nonce yet 401 DPoP error="use_dpop_nonce", error_description="Resource server requires nonce in DPoP proof", algs="ES256 EdDSA PS256"
retry with the server's nonce 200 {"orders":[],"client":"orders-cli"}
proof sent: {"typ":"dpop+jwt","alg":"ES256","jwk":{"kty":"EC","x":"M5K5xM809NM5egv0kblUzobF_h2cNudX-6ENCI0MjiQ","y":"qtatemMO_oibrlk6VpoXDbHPtCiZNksoyVMcIGRx33w","crv":"P-256"}} {"jti":"993999b4-d7f8-4018-9f8e-e20f2a109a71","htm":"GET","htu":"http://127.0.0.1:4000/orders","ath":"CFA2MS0HVrzwNCH1DJZgOjreMZ0qSfyrNWXR6W-rKb8","nonce":"ItQnwia8Goi2bVqV4toJFQ","iat":1792497394}
dpopFetch, no nonce known (2 requests) 200 {"orders":[],"client":"orders-cli"}
same proof replayed 401 DPoP error="invalid_dpop_proof", error_description="DPoP proof already used", algs="ES256 EdDSA PS256"
stolen token sent as Bearer 401 DPoP error="invalid_token", error_description="DPoP authorization scheme required", algs="ES256 EdDSA PS256"
stolen token, attacker's own key 401 DPoP error="invalid_token", error_description="Invalid DPoP key binding", algs="ES256 EdDSA PS256"
GET proof used for DELETE 401 DPoP error="invalid_dpop_proof", error_description="htm GET != DELETE", algs="ES256 EdDSA PS256"
proof made for another access token 401 DPoP error="invalid_dpop_proof", error_description="ath does not match the access token", algs="ES256 EdDSA PS256"
proof created two minutes ago 401 DPoP error="invalid_dpop_proof", error_description="'iat' claim timestamp check failed (too far in the past)", algs="ES256 EdDSA PS256"
What the run shows:
- The binding is real. Keycloak returned
token_type DPoPand acnf.jktequal to the thumbprint of the client's key. It also adds a non-standard member,kc-jkt-type, next tojkt; resource servers must ignore members they do not know, as the verifier does. - A stolen token is not enough. Sent as
Bearerit was refused; sent with a valid proof from the attacker's own key it failed the binding check. A copied request failed too: the same proof could not be replayed, a proof forGETdid not work forDELETE, and a proof created two minutes earlier was too old. athties a proof to one token. A valid proof made for a different access token of the same client and key was rejected.- Nonces cost one round trip. The first request without a nonce got
401 use_dpop_nonce;dpopFetchneeded 2 requests for its first call and 1 afterwards.
Keycloak's token endpoint enforces proofs as well. A second short script, reusing the client module:
// Does Keycloak's token endpoint accept the same DPoP proof twice? And a proof with a wrong htu?
import { dpopProof } from "./dpop-client.mjs";
const ISSUER = "http://127.0.0.1:8080/realms/demo";
const { token_endpoint } = await (await fetch(`${ISSUER}/.well-known/openid-configuration`)).json();
const send = (proof) =>
fetch(token_endpoint, {
method: "POST",
headers: {
"content-type": "application/x-www-form-urlencoded",
authorization: `Basic ${Buffer.from(`orders-cli:${process.env.CLI}`).toString("base64")}`,
DPoP: proof,
},
body: "grant_type=client_credentials",
});
const proof = await dpopProof({ method: "POST", url: token_endpoint });
for (const label of ["first use of a proof", "same proof again"]) {
const res = await send(proof);
const body = await res.json();
console.log(`${label.padEnd(24)} ${res.status} ${body.token_type ?? JSON.stringify(body)}`);
}
const wrongUri = await send(await dpopProof({ method: "POST", url: `${ISSUER}/protocol/openid-connect/userinfo` }));
console.log(`${"proof for another URI".padEnd(24)} ${wrongUri.status} ${await wrongUri.text()}`);
first use of a proof 200 DPoP
same proof again 400 {"error":"invalid_request","error_description":"DPoP proof has already been used"}
proof for another URI 400 {"error":"invalid_request","error_description":"DPoP HTTP URL mismatch"}
One deviation to know about: RFC 9449 §5 says an invalid proof at the token endpoint gets the error code invalid_dpop_proof, while Keycloak 26.8.1 answered invalid_request for every proof problem above. Write clients that branch on use_dpop_nonce only, and treat other errors from a DPoP token request as failures to report rather than codes to match.
Nonces: why servers ask for them
Without a server nonce, a proof's freshness rests on iat, a value the client chooses. Anyone who controls the client, including its legitimate user, can sign proofs dated in the future and use them later from a machine without the key (§11.2, "pre-generation"). A server-provided nonce, unpredictable and rotated by the server, makes proofs expire on the server's clock. It also removes clock skew from the picture: the server can encode its own time in the nonce instead of trusting iat (§11.1).
Rules from §8 and §9:
- Authorization servers signal with
400and"error": "use_dpop_nonce", resource servers with401andWWW-Authenticate: DPoP error="use_dpop_nonce". Both send the value inDPoP-Nonce. - Once a server has sent a nonce, it must not accept proofs without one (§11.3).
- A server may send the next nonce in a successful response, saving the extra round trip; such responses should not be cacheable.
- Browser clients only see
DPoP-NonceandWWW-Authenticateif the server lists them inAccess-Control-Expose-Headers. Without that, a single-page app cannot read the nonce it is being asked to use.
Okta's authorization server, for example, answers the first DPoP token request with "Authorization server requires nonce in DPoP proof" (Okta guide), so clients need the retry from day one.
Binding the authorization code: dpop_jkt
DPoP binds tokens at the token endpoint, but the authorization code is issued earlier, through the browser. With dpop_jkt in the authorization request (§10), the client announces the thumbprint of the key it will use, and the authorization server rejects a token request whose proof uses a different key. Together with PKCE this protects the flow end to end, provided the client uses a fresh key per authorization request. FAPI 2.0 requires authorization servers that use DPoP to support this binding, while leaving its use optional for clients (FAPI 2.0 Security Profile §5.3.2.1 and §5.3.3.1).
When to use DPoP
| Situation | DPoP? |
|---|---|
| Single-page app or mobile app holding refresh tokens | Yes: refresh tokens issued to public clients are bound to the key, which is where DPoP helps most |
| FAPI 2.0 (open banking and similar) | Required in practice: FAPI 2.0 demands sender-constrained tokens via mTLS or DPoP |
| Server-to-server with mutual TLS already in place | mTLS binding (RFC 8705) is the simpler choice |
| Confidential web app that never exposes tokens to the browser | Optional; tokens stay server-side, and its refresh tokens are already bound to client authentication |
Resource servers you do not control that only accept Bearer | Limited: there the token can only be used as a bearer token, without the protection (§7.2) |
Keycloak (tested above), Okta and Auth0 (documentation) document DPoP for their token endpoints, and servers advertise the accepted proof algorithms as dpop_signing_alg_values_supported in their metadata (§5.1). The gap is usually on the resource server side, which is why the verifier above matters.
Common mistakes
- Checking the proof but not the binding. A proof signed by any key is valid on its own; only
cnf.jktties it to the token. - Accepting the token as
Beareras well. Then the attacker simply uses the Bearer scheme. - Comparing
htuwith the query string or an internal URL.htuexcludes query and fragment and must match the public URL after normalization. - No replay store and no nonce. Then a copied proof works again for the whole
iatwindow. - A new key per request. The key must stay the same for as long as the tokens bound to it are in use.
- A symmetric
alg, or a private key injwk. Both are forbidden by §4.2; jose'sEmbeddedJWKrefuses the latter.
Sources
- RFC 9449: OAuth 2.0 Demonstrating Proof of Possession (DPoP), §4 to §11
- RFC 7638: JSON Web Key (JWK) Thumbprint
- RFC 8705: OAuth 2.0 Mutual-TLS Client Authentication and Certificate-Bound Access Tokens
- RFC 6750: Bearer Token Usage
- FAPI 2.0 Security Profile, §5.3
- Okta: Configure OAuth 2.0 DPoP and Auth0: DPoP
- jose:
EmbeddedJWK,calculateJwkThumbprint