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 is DPoP, not Bearer.
  • A DPoP proof: a short JWT in the DPoP header, 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

FieldMeaning
typ (header)must be dpop+jwt
jwk (header)the public key, so the server can verify
htmHTTP method — binds the proof to POST, not GET
htuthe URL, minus query and fragment
iatissued at — must be recent
jtiunique id, for replay detection
athhash of the access token (required at resource servers)
nonceserver-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

Example · http
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.

Example · javascript
// 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.

Example · javascript
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.

Example · javascript
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

  • Be the first to comment on this lesson.