Thoryn
  • 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.

A leaked bearer token vs a DPoP-bound token: in the first case the attacker reuses the token directly; in the second the access token's cnf.jkt thumbprint must match the public key on the DPoP proof, so the bare token is useless without the private key

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.jkt equals the SHA-256 thumbprint of the proof's public key
  • No proof on the optional client → 200, no cnf claim — backwards-compatible
  • No proof on the required client → 400 invalid_dpop_proof
  • Wrong htm, wrong htu, or iat outside the 60-second window → 400 invalid_dpop_proof
  • Discovery advertises dpop_signing_alg_values_supported so 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.