RFC 9449 — Demonstrating Proof of Possession (DPoP) — Versola Docs
VersolaVersola/docs
versola.kzGitHub

RFC 9449 — Demonstrating Proof of Possession (DPoP)

OAuth 2.0 Demonstrating Proof-of-Possession at the Application Layer

Specification: RFC 9449

DPoP binds an access token — and, where the client requests offline access, the refresh token issued alongside it — to a key pair the client holds, so a token intercepted without the private key cannot be replayed by another party. auth issues and verifies proofs at the token endpoint and /userinfo; edge, the resource-server component in front of proxied APIs, enforces the full set of checks — including a nonce it always requires — on every proxied call.

Proof JWT Validation

  • typ: dpop+jwt header
  • Signing algorithm allowlist — read from dpop_signing_alg_values_supported in the authorization server metadata document, so the set clients discover and the set a proof is held to are the same value. ES256 and PS256 where the document is silent; RS256 has a verifier but is left out of that default (FAPI 2.0 disallows it, so a deployment that wants it has to name it)
  • Public key carried in the proof’s own jwk header, verified against the key it embeds — never a kid pointing at a key this server issued
  • Signature verification against that embedded key
  • htm — bound to the current request’s HTTP method
  • htu — bound to the current request’s URI, with query and fragment stripped before comparison
  • iat — checked against a configurable leeway (dpop.iat-leeway, 60s default), symmetric in both directions
  • jti — required, used for the single-use check below

Token Endpoint

  • DPoP is opt-in per request — a request with no DPoP header still issues a plain bearer token
  • Exactly one DPoP header required — more than one is rejected before any proof is parsed
  • cnf.jkt — the proving key’s JWK thumbprint, bound into the issued access token
  • token_type reflects the binding — DPoP when cnf.jkt is present, Bearer otherwise
  • error="use_dpop_nonce" with a fresh DPoP-Nonce response header on the refusal, for the client to retry over
  • Server-mandated nonce — dpop.require-nonce makes a nonce compulsory on every proof this endpoint checks. Off by default: it costs each client an extra round trip, and unlike a proxied API call a token request is not something a captured proof buys much against

Resource Endpoints (/userinfo)

  • ath — the proof must bind to the access token actually presented (base64url(sha256(token)))
  • cnf.jkt match — a proof signed with any key other than the one the token was bound to at issuance is refused
  • Bearer downgrade refused — a DPoP-bound token presented under the Bearer scheme is rejected outright per §7.2, not silently accepted
  • WWW-Authenticate: DPoP error="invalid_dpop_proof", and error="use_dpop_nonce" with a DPoP-Nonce header, on the matching failures
  • Exactly one DPoP header required, same as the token endpoint
  • Server-mandated nonce — the same dpop.require-nonce switch, applied here too

Edge assertion (Versola extension, not part of RFC 9449)

edge terminates DPoP itself for these same bound tokens (see below) and separately calls back to /userinfo on the user’s behalf to build its authorization context — a call it cannot attach a proof to, because the client’s private key never leaves the client. Rather than accepting an unproven Bearer presentation of a bound token, auth accepts a short-lived, edge-signed assertion (Versola-Edge-Assertion header) in its place, scoped to the tenant that edge is registered to serve and checked for replay against the same ring client proofs use. This is a closed exception for one trusted internal caller, not a general bearer fallback: an assertion signed for the wrong tenant, from an edge that isn’t registered, or replayed is refused exactly as if nothing had been sent at all.

Edge Resource Server (proxied API calls)

  • ath and cnf.jkt binding, same checks as /userinfo
  • Nonce required on every proof, unconditionally — no configuration skips this check (§9, §11.3)
  • Exactly one DPoP header required
  • htu rebuilt from configuration rather than trusted from the request’s own Host header

Replay Protection

  • Single-use enforcement — a proof’s (jkt, jti) pair is recorded on first use and any repeat is rejected
  • Cross-replica — a shared, UNLOGGED, partitioned Postgres ring, not an in-process cache, so a proof replayed against a second instance inside the iat window is caught, not just a same-instance replay
  • Slot-based eviction — proofs are filed into fixed-width time slots by their own iat, and a whole slot is truncated once nothing acceptable can still land in it, rather than deleted row by row
  • edge additionally keeps a local in-memory ring in front of the shared one — checked first, with the shared store consulted only on a local miss — and fails degraded, not closed, if the shared store is unreachable, falling back to local-only protection instead of rejecting every proxied call

Refresh Token Binding

  • A refresh token issued alongside a DPoP-bound access token is itself sender-constrained — a later refresh grant must present a proof for the same key
  • Key mismatch on refresh — invalid_grant, not a silent re-bind to whatever key shows up
  • Bound refresh tokens do not rotate — a token demonstrably still held by the party it was issued to has nothing to gain from rotation, so the rotation chain, its idempotency key, and its retry state are not carried for it
  • Introspection reflects the binding — token_type: "DPoP" and cnf.jkt on a bound token’s introspection response

Authorization Server Metadata

  • dpop_signing_alg_values_supported — advertised, and the only place the accepted set is written down: auth reads the algorithms it checks a proof against straight off the served document rather than from its own config, so the two cannot drift apart. An algorithm named there that auth has no verifier for is dropped instead of advertised, so the document never promises one a proof would then be refused for

Client Metadata

  • dpop_bound_access_tokens — a client registered with it always uses DPoP: a token request from it that carries no proof is refused with invalid_dpop_proof on every grant, rather than answered with a bearer token. Left off by default, so a client that has not asked for it keeps deciding per request

Authorization Code Binding

  • dpop_jkt authorization request parameter (§10) — a client commits at the authorization request to the key its code will be redeemed against, and the code carries that thumbprint through the login flow. Redemption with a proof for any other key, or with none, is refused with invalid_grant, and the code is left unspent so the committed client can still use it. A value that is not a base64url SHA-256 thumbprint is refused at /authorize, where the client can still be told why. The same parser backs /par, so a pushed request commits to it exactly the same way