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
DPoPheader: a fresh JWT withtyp: dpop+jwt, its public key in thejwkheader, and claimshtm(HTTP method),htu(URL),iat, a uniquejti, and for API callsath(hash of the access token). - The AS binds the token to the key's thumbprint (
cnf.jktclaim) and usestoken_type: DPoP; APIs requireAuthorization: DPoP <token>plus a valid proof that matches the method, URL and key. - Servers may demand a
nonce(viaDPoP-Nonce) to limit pre-generated proofs, and must reject replayedjtivalues 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,jtireplay and the key thumbprint.