DPoP: the token binding spec that kills bearer token theft

Bearer tokens have a fundamental design flaw: they are like cash. Whoever holds one can use it. If an attacker steals your access token — via XSS, a malicious browser extension, storage inspection, or a man-in-the-middle attack — they can make API calls as you until the token expires. DPoP (Demonstrating Proof of Possession), defined in RFC 9449, fixes this by binding the token to a specific asymmetric key pair that only the legitimate client controls.

How bearer token theft happens

The attack surface for bearer tokens stored in a browser is significant:

  • XSS: Cross-site scripting can read tokens from localStorage or in-memory JavaScript state. Even with HttpOnly cookies, a sufficiently powerful XSS can call your own API on behalf of the user.
  • Storage inspection: On mobile devices or desktops, tokens stored in SQLite, Keychain, or the filesystem can be extracted by a malicious app with appropriate permissions.
  • Network interception: HTTPS protects the wire, but certificate pinning failures, compromised CAs, or misconfigured TLS can expose tokens in transit.
  • Log leakage: Tokens occasionally end up in server logs if passed as query parameters or in headers that are logged verbatim.

In all of these cases, a standard bearer token can be replayed from anywhere by anyone. DPoP makes a stolen token useless without also stealing the private key.

DPoP concept: proof JWTs

DPoP works by requiring the client to demonstrate possession of a private key at the time of every API request. Here is the mechanism:

  1. The client generates an EC key pair (P-256 recommended) and keeps the private key in memory or a secure enclave. The private key never leaves the client.
  2. When requesting a token, the client includes a DPoP proof JWT in the request header. This JWT is signed with the private key and contains the public key, the HTTP method, the request URI, and a timestamp.
  3. The authorization server issues an access token that is cryptographically bound to the client's public key (the token contains a hash of the public key in its cnf.jkt claim).
  4. For every API request, the client generates a fresh DPoP proof JWT (with the current timestamp and target URL), and sends it alongside the access token.
  5. The resource server verifies that the DPoP proof was signed by the key whose thumbprint matches the token's cnf.jkt claim, and that the proof covers this specific request.

An attacker who steals the access token cannot use it: they do not have the private key, so they cannot generate valid DPoP proofs. The token is bound.

DPoP proof JWT structure

// DPoP Proof JWT header
{
  "typ": "dpop+jwt",
  "alg": "ES256",
  "jwk": {
    "kty": "EC",
    "crv": "P-256",
    "x": "l8tFrhx-34tV3hRICRDY9zCkDlpBhF42UMUBa4GFrj4",
    "y": "9VE4jf_Ok_o64zbTTlcuNJajHmt6v9TDVrU0CdvGRDA"
    // Note: no private key 'd' field — public key only
  }
}

// DPoP Proof JWT payload
{
  "jti": "e1redlj...",         // unique ID per proof (replay prevention)
  "htm": "GET",                 // HTTP method — bound to this method
  "htu": "https://api.example.com/users/me",  // bound to this URL
  "iat": 1690000000,           // issued at — proof is only valid briefly
  "nonce": "server_issued_nonce"  // server-supplied nonce (optional, prevents replay)
}

Implementation in JavaScript

// Generate DPoP key pair (browser Web Crypto API)
async function generateDPoPKeyPair() {
  return await crypto.subtle.generateKey(
    { name: 'ECDSA', namedCurve: 'P-256' },
    false,         // non-extractable — private key cannot leave this context
    ['sign', 'verify']
  );
}

// Create a DPoP proof JWT
async function createDPoPProof(privateKey, publicKey, method, url, nonce) {
  const jwk = await crypto.subtle.exportKey('jwk', publicKey);
  // Remove private key fields just in case
  const { d, ...publicJwk } = jwk;

  const header = {
    typ: 'dpop+jwt',
    alg: 'ES256',
    jwk: publicJwk
  };

  const payload = {
    jti: crypto.randomUUID(),
    htm: method.toUpperCase(),
    htu: url,
    iat: Math.floor(Date.now() / 1000),
    ...(nonce ? { nonce } : {})
  };

  const encode = obj =>
    btoa(JSON.stringify(obj))
      .replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');

  const signingInput = `${encode(header)}.${encode(payload)}`;
  const signature = await crypto.subtle.sign(
    { name: 'ECDSA', hash: 'SHA-256' },
    privateKey,
    new TextEncoder().encode(signingInput)
  );

  const sigB64 = btoa(String.fromCharCode(...new Uint8Array(signature)))
    .replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');

  return `${signingInput}.${sigB64}`;
}

// Token request with DPoP
const keyPair = await generateDPoPKeyPair();

const dpopProof = await createDPoPProof(
  keyPair.privateKey,
  keyPair.publicKey,
  'POST',
  'https://auth.yourdomain.com/oauth2/token'
);

const tokenResponse = await fetch('https://auth.yourdomain.com/oauth2/token', {
  method: 'POST',
  headers: {
    'Content-Type': 'application/x-www-form-urlencoded',
    'DPoP': dpopProof
  },
  body: new URLSearchParams({
    grant_type: 'authorization_code',
    code: authCode,
    code_verifier: codeVerifier,
    client_id: CLIENT_ID,
    redirect_uri: REDIRECT_URI
  })
});

// API request with DPoP-bound token
async function apiRequest(url, method, accessToken) {
  const proof = await createDPoPProof(
    keyPair.privateKey,
    keyPair.publicKey,
    method,
    url
  );

  return fetch(url, {
    method,
    headers: {
      'Authorization': `DPoP ${accessToken}`,  // Note: DPoP not Bearer
      'DPoP': proof
    }
  });
}

Server-side verification

// Resource server DPoP verification (Node.js)
import { importJWK, jwtVerify, calculateJwkThumbprint } from 'jose';

async function verifyDPoP(req, accessToken) {
  const dpopHeader = req.headers['dpop'];
  if (!dpopHeader) throw new Error('Missing DPoP header');

  // Decode the DPoP proof without verification to extract the JWK
  const [headerB64] = dpopHeader.split('.');
  const header = JSON.parse(atob(headerB64.replace(/-/g, '+').replace(/_/g, '/')));

  if (header.typ !== 'dpop+jwt') throw new Error('Invalid DPoP typ');

  // Import the public key from the JWK in the proof header
  const publicKey = await importJWK(header.jwk, 'ES256');

  // Verify the proof signature and claims
  const { payload } = await jwtVerify(dpopHeader, publicKey, {
    typ: 'dpop+jwt'
  });

  // Verify the proof covers this specific request
  if (payload.htm !== req.method) throw new Error('Method mismatch');
  if (payload.htu !== `${req.protocol}://${req.hostname}${req.path}`) {
    throw new Error('URL mismatch');
  }

  // Verify the proof is fresh (within 60 seconds)
  const age = Math.floor(Date.now() / 1000) - payload.iat;
  if (age > 60) throw new Error('DPoP proof too old');

  // Verify the access token is bound to this key
  const thumbprint = await calculateJwkThumbprint(header.jwk);
  const tokenPayload = decodeJwtPayload(accessToken);
  if (tokenPayload.cnf?.jkt !== thumbprint) {
    throw new Error('Token not bound to this key');
  }

  // Check jti for replay (store in short-lived cache)
  if (await replayCache.has(payload.jti)) throw new Error('Replayed DPoP proof');
  await replayCache.set(payload.jti, true, 120); // 2 minute replay window
}

Nonce support

RFC 9449 includes an optional server-supplied nonce mechanism. The server can return a DPoP-Nonce header in responses, and subsequent requests must include that nonce in the proof payload. This gives the server additional control over proof freshness and makes replay attacks harder even if the client's clock is skewed. If your server returns a 401 with a fresh nonce, the client retries the request with a new proof including the nonce.

Limitations

DPoP is not a cure for all token security problems. It does not protect against an attacker who has full code execution on the client — if they can run JavaScript in your page context, they can call your API directly using the same key pair. DPoP's protection is specifically against token exfiltration: the token cannot be used from a different context. It also adds latency (one signature per request) and complexity to both client and server. The right use case is high-value tokens with longer lifetimes, or environments where token storage security is uncertain (browser localStorage, mobile app keychain).

← Back to blog Try Bastionary free →