Skip to content
ecdsa.com
← All articles

DPoP explained: binding OAuth tokens to a client key

A bearer token works for whoever holds it. A DPoP-bound token works only together with a fresh signature from the client's private key, so a copied token is useless on its own. Here is the protocol, client and server code, and what a resource server rejected when Keycloak-issued tokens and proofs were stolen, replayed and reused.

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

  1. The client generates a key pair, ideally non-extractable, and keeps it for the lifetime of its tokens.
  2. Token request. The client sends its normal token request with a DPoP header containing a proof for POST and the token endpoint URL. If the authorization server wants a nonce, it answers 400 with use_dpop_nonce and a DPoP-Nonce header, and the client retries with that nonce in the proof.
  3. Token response. token_type is DPoP, not Bearer. 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).
  4. Resource request. Authorization: DPoP <access token> plus a new proof for this method and URL, now including ath, the base64url SHA-256 hash of the access token.
  5. The resource server checks the proof and the binding, and may itself demand a nonce with 401 and WWW-Authenticate: DPoP error="use_dpop_nonce" (§9).

The proof carries these members (§4.2):

WhereMemberMeaning
headertypdpop+jwt, so a proof can never be confused with another JWT
headeralgasymmetric only; never none or HS*
headerjwkthe client's public key; never a private key
payloadjtiunique per proof: at least 96 random bits, or a UUID v4
payloadhtm, htuHTTP method, and target URI without query and fragment
payloadiatcreation time
payloadathhash of the access token, required when one is presented
payloadnoncethe server's latest DPoP-Nonce, when it sent one

Client: making proofs

dpop-client.mjs (jose 6.2.13, Node.js 24.22)
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:

dpop-verify.mjs (jose 6.2.13)
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:

  • publicOrigin is configuration, not the Host header. 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:

Output: Keycloak 26.8.1 and the resource server above (node:24.22.0-bookworm-slim)
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 DPoP and a cnf.jkt equal to the thumbprint of the client's key. It also adds a non-standard member, kc-jkt-type, next to jkt; resource servers must ignore members they do not know, as the verifier does.
  • A stolen token is not enough. Sent as Bearer it 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 for GET did not work for DELETE, and a proof created two minutes earlier was too old.
  • ath ties 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; dpopFetch needed 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:

as-replay.mjs
// 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()}`);
Output
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 400 and "error": "use_dpop_nonce", resource servers with 401 and WWW-Authenticate: DPoP error="use_dpop_nonce". Both send the value in DPoP-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-Nonce and WWW-Authenticate if the server lists them in Access-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

SituationDPoP?
Single-page app or mobile app holding refresh tokensYes: 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 placemTLS binding (RFC 8705) is the simpler choice
Confidential web app that never exposes tokens to the browserOptional; tokens stay server-side, and its refresh tokens are already bound to client authentication
Resource servers you do not control that only accept BearerLimited: 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

  1. Checking the proof but not the binding. A proof signed by any key is valid on its own; only cnf.jkt ties it to the token.
  2. Accepting the token as Bearer as well. Then the attacker simply uses the Bearer scheme.
  3. Comparing htu with the query string or an internal URL. htu excludes query and fragment and must match the public URL after normalization.
  4. No replay store and no nonce. Then a copied proof works again for the whole iat window.
  5. A new key per request. The key must stay the same for as long as the tokens bound to it are in use.
  6. A symmetric alg, or a private key in jwk. Both are forbidden by §4.2; jose's EmbeddedJWK refuses the latter.

Sources