Sender-constrained tokens: DPoP and mTLS

OAuth, OIDC & JWT · Expert · 15 min

Bearer tokens are only as safe as every place they pass through; binding them closes that gap.

A bearer token works for anyone holding it. Sender-constrained tokens additionally require proof of possession of a private key that never leaves the client.

DPoP (RFC 9449, Demonstrating Proof of Possession) works at the application layer:

  • The client generates a key pair (in the browser via WebCrypto with a non-extractable key, or in secure storage on mobile).
  • On each token request and API call it sends a DPoP header: a fresh JWT with typ: dpop+jwt, its public key in the jwk header, and claims htm (HTTP method), htu (URL), iat, a unique jti, and for API calls ath (hash of the access token).
  • The AS binds the token to the key's thumbprint (cnf.jkt claim) and uses token_type: DPoP; APIs require Authorization: DPoP <token> plus a valid proof that matches the method, URL and key.
  • Servers may demand a nonce (via DPoP-Nonce) to limit pre-generated proofs, and must reject replayed jti values within the acceptance window.

mTLS-bound tokens (RFC 8705) bind the token to the client's TLS certificate (cnf.x5t#S256). Strong and transparent for server-to-server traffic, impractical in browsers.

Binding defends against token leakage through logs, proxies, compromised APIs and stolen storage. It does not stop an attacker who can run code inside the client (XSS can still make requests), so it complements, not replaces, the earlier defences. FAPI 2.0 security profiles require sender-constrained tokens.

Example

import { generateKeyPair, exportJWK, SignJWT, calculateJwkThumbprint } from "jose";
import { createHash, randomUUID } from "node:crypto";

const { publicKey, privateKey } = await generateKeyPair("ES256");
const jwk = await exportJWK(publicKey);

async function dpopProof(method, url, accessToken) {
  const claims = { htm: method, htu: url, jti: randomUUID() };
  if (accessToken) claims.ath = createHash("sha256").update(accessToken).digest("base64url");
  return new SignJWT(claims)
    .setProtectedHeader({ typ: "dpop+jwt", alg: "ES256", jwk })
    .setIssuedAt().sign(privateKey);
}

const proof = await dpopProof("GET", "https://api.example.com/orders", "eyJ...token");
console.log(proof.split(".").length); // 3
// The AS binds the token to this thumbprint (cnf.jkt):
console.log((await calculateJwkThumbprint(jwk)).length); // 43
// fetch(url, { headers: { Authorization: "DPoP eyJ...token", DPoP: proof } })

Remember

  • Sender-constrained tokens need proof of a private key
  • DPoP proofs are per-request JWTs bound to method, URL and token hash
  • mTLS binding fits server-to-server traffic
  • Binding stops replay of leaked tokens, not in-client attacks

Watch out for

  • Accepting DPoP proofs without checking htm, htu, jti replay and the key thumbprint.

Check yourself

  1. What stops a stolen DPoP-bound access token from being used by an attacker?

Sign in to answer, see the explanations, and get this lesson back as a quick review later.

Official docs: www.rfc-editor.org/rfc/rfc9449