- oauth2
- security
- dpop
- fapi
Bearer is not enough — DPoP token binding (RFC 9449)
Bearer tokens are bearer. Whoever holds one is the user. DPoP swaps that for "whoever holds the keypair the token is bound to" — same OAuth flow, very different threat model.
3 Jun 2026 · Mark Bakker
The clue is in the name. Bearer token. Whoever bears it is the user, full stop. The protocol does not care how you got it — copy-pasted from a Slack thread, fished from an Nginx access log, mirrored by a misconfigured CDN. If you have the bytes, you are the user.
That model rests on the assumption that access tokens do not leak. The assumption is wrong, and has always been wrong. DPoP — Demonstrating Proof of Possession, RFC 9449 — replaces "holds the token" with "holds the keypair the token is bound to." Same OAuth shape. Very different threat model.
The Thoryn authorization hub now issues DPoP-bound tokens per client. Here is what that buys, what it costs, and when you pick it over mTLS.
The bearer problem, concretely
The leak surfaces are not exotic. A backend logs the full Authorization header during an outage. A misconfigured proxy mirrors request bodies into a debug log everyone forgot was on. A client SDK includes the bearer in a stack trace it ships to its crash reporter. None is a CVE. All of them put a token somewhere it should not be.
Once the token is outside the legitimate client, the only thing protecting the user is its lifetime. Five minutes, sixty minutes, eight hours — pick a number; the attacker has that long with full user rights. Refresh-token rotation does not help; this is the access token. The fix is not "be more careful with logs." The fix is to make the token unusable without something the attacker cannot copy out of a log.
What DPoP changes
Every API call carries a DPoP header — a short JWT signed by the client's private key, with the public JWK embedded in its header. The payload nails the proof to one specific request: htm (method), htu (URI), iat (issued-at, must be fresh — 60 seconds), and jti (a unique ID the resource server can cache to detect replay).
When the hub issues an access token to a DPoP-using client, it embeds a cnf.jkt claim — the SHA-256 thumbprint of the client's public key. The resource server's check is mechanical: does the JKT in the access token equal the thumbprint of the key that signed the DPoP header on this request? If yes, accept. If no, reject.
The bare access token, leaked into a log, buys the attacker nothing. They cannot mint a fresh DPoP proof — that requires the private key, which lives only inside the client.
What we ship
A per-client DPoP-required flag, stored alongside the rest of the token settings. With the flag set, the hub requires a DPoP proof on every token request and refuses to issue an unbound token. With it unset, DPoP is optional: a proof binds the token; no proof issues a regular bearer. Two clients, two postures, no global switch.
The acceptance suite drives both modes through client_credentials:
- Valid proof on the optional client → 200,
cnf.jktequals the SHA-256 thumbprint of the proof's public key - No proof on the optional client → 200, no
cnfclaim — backwards-compatible - No proof on the required client → 400
invalid_dpop_proof - Wrong
htm, wronghtu, oriatoutside the 60-second window → 400invalid_dpop_proof - Discovery advertises
dpop_signing_alg_values_supportedso well-behaved clients negotiate
The htm and htu checks defeat the obvious follow-up: an attacker who copies a DPoP proof out of one request cannot reuse it against a different method or endpoint.
What it costs
DPoP is not free. Three costs are real.
Per-request work. Every API call needs a fresh proof — fresh iat, fresh jti, signed with the client's key. ECDSA on P-256 is well under a millisecond, but it is per-call. Bearer is a header copy; DPoP is a sign-then-header.
Real keypair management. The client's private key has to live somewhere durable, somewhere that survives restarts, somewhere an attacker on the same host cannot trivially read. For server-to-server clients: a file with tight permissions or a KMS handle. For a browser SPA: non-extractable WebCrypto keys plus service-worker plumbing.
Clock sync. The 60-second freshness window is small enough that wandering clocks become a real source of invalid_dpop_proof. NTP-on-a-cron is fine; "we'll sync it eventually" is not.
None of this is prohibitive. All of it is a reason not to flip DPoP on for a low-stakes read-only API. Pick the surfaces where a leaked token would be a real incident.
DPoP vs mTLS
Both achieve sender-constrained tokens. They live at different layers, with different operational profiles.
mTLS (RFC 8705) is transport-layer: the binding rides on the TLS handshake. Resource servers see the verified client certificate from their TLS terminator and check cnf.x5t#S256 on the access token. No per-request app work. The downside is that every TLS-terminating proxy in the path has to pass the client cert through cleanly. CDNs, WAFs, ingress controllers — every one of them is a place the chain can break.
DPoP is application-layer: a JWT in an HTTP header. It works through anything that forwards HTTP — proxies, gateways, CDNs, service meshes — without coordination. The price is that the application does the work: clients sign per-request, resource servers parse and verify.
The rough rule: server-to-server clients on a controlled network favour mTLS; clients that traverse hops you don't own (mobile, SPAs, anything behind a managed CDN) favour DPoP. They are not mutually exclusive — the same hub can issue both, for different clients.
The FAPI 2 angle
FAPI 2.0 Security Profile is the OpenID Foundation's high-assurance profile — open banking, healthcare, anywhere the consequence of a stolen access token is "money moves" or "records leak." FAPI 2 mandates sender-constrained tokens and permits exactly two mechanisms: DPoP or mTLS. Bearer-only is not a FAPI 2 deployment. If your tokens carry neither cnf.jkt nor cnf.x5t#S256, the claim is wrong.
That is the practical reason DPoP is in the hub now, ahead of any specific tenant asking for it. Regulated tenants will need at least one sender-constraint mechanism on day one. DPoP is the lower-friction half: no proxy coordination, no certificate lifecycle — just a flag on the client.
Bearer was the right default in 2012. It is no longer the right default for anything that matters.