Token binding: the spec that died, and DPoP: the spec that lived

Bearer tokens have a fundamental property: whoever presents them wins. A token stolen from a user's device, from a network interception, or from a server-side cache can be used by an attacker without any additional proof of identity. Both RFC 8471 Token Binding and RFC 9449 DPoP (Demonstrating Proof-of-Possession) were designed to solve this problem — to make tokens "sender-constrained" so that only the client that obtained them can use them. Token Binding failed for architectural reasons. DPoP succeeded where Token Binding couldn't, and is now supported by major authorization servers.

The sender-constraint problem

A bearer token is like a hotel key card: whoever has the physical card gets access. No other proof required. For most application tokens, this is an acceptable tradeoff — tokens are short-lived, the channels are encrypted, and practical interception is difficult. But for high-value scenarios (financial APIs, healthcare data, long-lived tokens), the inability to tie a token to a specific holder is a real risk.

Sender-constrained tokens require the presenter to prove they control a private key that was bound to the token at issuance. Even if an attacker steals the token, they can't use it without the corresponding private key.

Token Binding: the TLS-layer approach

RFC 8471 Token Binding was published in 2018. Its approach was elegant: bind the OAuth token to the TLS session itself, using the TLS channel's keying material. A Token Binding ID was derived from a browser-generated key pair and included in HTTP requests via a Sec-Token-Binding header. The authorization server would embed this binding in the token. On subsequent requests, the resource server could verify that the same TLS channel (same key pair) was being used.

Chrome implemented Token Binding as an experimental feature, and it showed real promise for high-security scenarios. The FIDO Alliance and IETF were both invested in it. Then, in 2020, the Chrome team announced they were removing Token Binding support and would not be shipping it.

The core problem was architectural: Token Binding required TLS-layer participation from the browser, the network stack, and the server — something that's very difficult to retrofit into existing infrastructure and impossible for reverse proxies and load balancers to pass through without terminating and re-establishing the binding. Every TLS termination point in a deployment (and modern cloud deployments have many) broke the binding chain. The spec solved a real problem but required a level of infrastructure coordination that wasn't achievable at web scale.

DPoP: the application-layer approach

DPoP (RFC 9449, published 2023) solves the same sender-constraint problem at the application layer instead of the TLS layer. Rather than relying on the TLS session, DPoP requires the client to generate an asymmetric key pair and prove possession of the private key on every request by signing a proof JWT.

The flow works as follows:

  1. The client generates an EC or RSA key pair (ephemeral, or reused across a session).
  2. During the token exchange, the client sends a DPoP header containing a signed JWT with the public key and the token endpoint URL. The authorization server binds the issued token to this public key by embedding a thumbprint of the key in the token's cnf (confirmation) claim.
  3. On every subsequent API request, the client generates a new DPoP proof JWT signed with the same private key, including the HTTP method, request URI, and a timestamp.
  4. The resource server verifies the DPoP proof: correct signature, matching public key (against the cnf claim), correct method/URI, and a fresh timestamp (to prevent replay).
import { SignJWT, generateKeyPair, exportJWK, calculateJwkThumbprint } from 'jose';

// Generate a DPoP key pair (done once per session or per-request)
const { privateKey, publicKey } = await generateKeyPair('ES256');
const publicKeyJwk = await exportJWK(publicKey);

async function createDpopProof(
  method: string,
  url: string,
  accessToken?: string
): Promise {
  const jwk = await exportJWK(publicKey);

  const proofPayload: Record<string, any> = {
    jti: crypto.randomUUID(),     // unique per proof, prevents replay
    htm: method.toUpperCase(),    // HTTP method
    htu: url,                     // HTTP URI (no query string or fragment)
    iat: Math.floor(Date.now() / 1000),
  };

  // When bound to an access token, include the token hash
  if (accessToken) {
    const tokenHash = crypto
      .createHash('sha256')
      .update(accessToken)
      .digest('base64url');
    proofPayload.ath = tokenHash;
  }

  return new SignJWT(proofPayload)
    .setProtectedHeader({
      alg: 'ES256',
      typ: 'dpop+jwt',
      jwk: { kty: jwk.kty, crv: jwk.crv, x: jwk.x, y: jwk.y },  // public key, no private key!
    })
    .sign(privateKey);
}

// Using DPoP in a fetch request
async function dpopFetch(url: string, options: RequestInit = {}): Promise {
  const method = options.method ?? 'GET';
  const dpopProof = await createDpopProof(method, url, currentAccessToken);

  return fetch(url, {
    ...options,
    headers: {
      ...options.headers,
      'Authorization': `DPoP ${currentAccessToken}`,  // note: DPoP, not Bearer
      'DPoP': dpopProof,
    },
  });
}

Server-side DPoP verification

import { jwtVerify, importJWK } from 'jose';

async function verifyDpopProof(
  dpopHeader: string,
  method: string,
  url: string,
  expectedKeyThumbprint: string  // from the access token's cnf.jkt claim
): Promise {
  // Decode the DPoP proof header to get the embedded public key
  const [headerB64] = dpopHeader.split('.');
  const header = JSON.parse(Buffer.from(headerB64, 'base64url').toString());

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

  const publicKey = await importJWK(header.jwk, header.alg);

  const { payload } = await jwtVerify(dpopHeader, publicKey, {
    typ: 'dpop+jwt',
    clockTolerance: 5,
  });

  // Verify claims
  if (payload.htm !== method.toUpperCase()) throw new Error('Method mismatch');
  if (payload.htu !== url) throw new Error('URL mismatch');

  // Verify freshness: DPoP proofs expire quickly (60 seconds)
  const age = Math.floor(Date.now() / 1000) - (payload.iat as number);
  if (age > 60) throw new Error('DPoP proof expired');

  // Verify the public key matches what was bound in the access token
  const thumbprint = await calculateJwkThumbprint(header.jwk);
  if (thumbprint !== expectedKeyThumbprint) throw new Error('Key mismatch');
}
DPoP proofs must be generated fresh for every request — they contain the specific HTTP method and URL. You cannot reuse a DPoP proof for different endpoints or different methods. This is by design: it prevents an attacker who captures a proof from replaying it against a different resource.

DPoP succeeds where Token Binding failed by operating entirely at the application layer. No TLS termination issues, no browser or network stack changes required. The tradeoff is that clients need to manage key pairs and generate proofs on every request, which adds CPU overhead on constrained devices. For most server-to-server API calls, this is negligible. For high-volume browser clients, benchmark before deploying.