RFC 9449 - OAuth 2.0 Demonstrating Proof of Possession (DPoP).
A DPoP proof is a JWS that a client signs with a key it holds and
attaches to a token request (POST /token) or to every
protected-resource request that uses a DPoP-bound access token. The
proof carries:
- a JOSE header with
typ: "dpop+jwt", an asymmetric signaturealg, and the client's public key injwk; - a JOSE payload with
htm(HTTP method),htu(HTTP target URI),iat(creation timestamp),jti(unique replay identifier), and - when presented alongside an access token -ath, the base64url-encoded SHA-256 hash of that access token.
The server validates the proof against the live request, computes the
RFC 7638 SHA-256 thumbprint of the embedded JWK, and uses the
thumbprint to bind issued/presented access tokens to the proof key via
the access token's cnf.jkt claim (RFC 7800).
This module verifies a single DPoP proof and returns the thumbprint and replay identifier so the caller (the token endpoint or the authenticated-request handler) can:
- compare
jktto the bound access token'scnf.jkt, and - persist
jtiin a replay cache.
It is framework-agnostic: no Plug, no database, no application config.
It is a pure function of the proof JWT, the HTTP request context, and
an optional access token. A resource server composes
Attesto.Token.verify/3 with this module's verify_proof/2.
Accepted algorithms
Per RFC 9449 §4.2, DPoP proofs MUST be signed with an asymmetric
algorithm. This verifier whitelists ES256, ES384, ES512, RS256,
RS384, RS512, PS256, PS384, PS512, legacy EdDSA, and RFC 9864
Ed25519 / Ed448, intersected with the algorithms the configured JOSE
backend reports as available. The explicit Edwards identifiers must match
the embedded public JWK's curve, and RSA proof keys must have a modulus of
at least 2048 bits. Symmetric algorithms (HS*) and the unsecured none
algorithm are rejected; there is no caller-facing knob to relax this.
Replay protection
RFC 9449 §11.1 requires the resource server to reject a DPoP proof it
has already seen. A captured-and-replayed proof is otherwise good for
the entire iat acceptance window (default 60 seconds). This verifier
enforces replay protection in two layers:
- The proof's
jtiis length-capped (see@max_jti_length) so an attacker cannot exhaust the cache by submitting proofs with megabyte-sizedjtivalues. - The proof's replay identity is recorded once, and rejected if already
seen, for the acceptance window plus a one-second integer-boundary margin
(
max_age_seconds+ future skew + 1 — seereplay_ttl/1). The success map returnsreplay_key/replay_ttlso the caller can record it; deriving the TTL from the verifier's age policy keeps the cache from forgetting the identity while the proof is still acceptable.Attesto.DPoP.ReplayCacheprovides a default ETS-backed store.
Claim the jti only after the whole request has authenticated
A protected-resource pipeline MUST reject a replayed proof, but it MUST
record the jti after the access token and its cnf.jkt binding
verify - not during proof verification. Recording it earlier lets a
captured-but-otherwise-valid proof (presented with no token, or a token
that will fail binding) burn the jti first, so the legitimate request
carrying that same proof is then rejected as a replay: a targeted denial
of service. The token endpoint has the same obligation - record the
identity only once the grant has validated.
So the correct shape is: call verify_proof/2 without :replay_check,
verify the access token and compare its cnf.jkt to the returned jkt,
and only then record the returned replay_key for replay_ttl seconds.
Record replay_key, not the raw jti: it namespaces the identifier by the
proof key (see the success map), so one key's proof cannot evict another's.
Attesto.Plug.Authenticate does exactly this.
The :replay_check opt records the identity inline, during verification,
before any of that. It exists for flows with no subsequent binding step
and for test scaffolding; a protected-resource or token pipeline should
prefer the defer-then-record shape above.
Summary
Functions
The list of JOSE alg values accepted on a DPoP proof's protected
header, filtered by the configured JOSE backend's current capabilities.
The ath claim value defined by RFC 9449 §4.3:
base64url(SHA-256(access_token)), unpadded.
RFC 7638 SHA-256 JWK thumbprint, base64url-encoded without padding.
Accepts a %JOSE.JWK{} or a JWK as a plain map (e.g. the one in a DPoP
proof's protected header).
Returns true iff the given access-token claims map advertises a DPoP
binding via RFC 7800 cnf.jkt. Tolerates any verifier-accepted
cnf.jkt value (non-empty string).
Verify a DPoP proof JWS per RFC 9449 against the given request context.
Types
@type nonce_check_fun() :: (String.t() | nil -> :ok | {:error, :use_dpop_nonce})
@type replay_check_fun() :: (String.t(), pos_integer() -> :ok | {:error, :replay})
@type verified_proof() :: %{ ath: String.t() | nil, htm: String.t(), htu: String.t(), iat: non_neg_integer(), jkt: String.t(), jti: String.t(), replay_key: String.t(), replay_ttl: pos_integer() }
@type verify_error() ::
:invalid_proof
| :invalid_signature
| :invalid_typ
| :invalid_alg
| :unsupported_critical_header
| :missing_jwk
| :invalid_jwk
| :invalid_htm
| :invalid_htu
| :missing_jti
| :invalid_jti
| :missing_ath
| :invalid_ath
| :missing_iat
| :invalid_iat
| :proof_expired
| :replay
| :use_dpop_nonce
@type verify_opts() :: [ http_method: String.t(), http_uri: String.t(), access_token: String.t() | nil, now: DateTime.t() | non_neg_integer(), max_age_seconds: pos_integer(), replay_check: replay_check_fun() | nil, nonce_check: nonce_check_fun() | nil ]
Functions
@spec allowed_algs() :: [String.t()]
The list of JOSE alg values accepted on a DPoP proof's protected
header, filtered by the configured JOSE backend's current capabilities.
The ath claim value defined by RFC 9449 §4.3:
base64url(SHA-256(access_token)), unpadded.
@spec compute_jkt(JOSE.JWK.t() | map()) :: String.t()
RFC 7638 SHA-256 JWK thumbprint, base64url-encoded without padding.
Accepts a %JOSE.JWK{} or a JWK as a plain map (e.g. the one in a DPoP
proof's protected header).
Returns true iff the given access-token claims map advertises a DPoP
binding via RFC 7800 cnf.jkt. Tolerates any verifier-accepted
cnf.jkt value (non-empty string).
@spec verify_proof(String.t(), verify_opts()) :: {:ok, verified_proof()} | {:error, verify_error()}
Verify a DPoP proof JWS per RFC 9449 against the given request context.
Required opts
:http_method- the HTTP method of the request the proof was attached to ("POST","GET", …). Compared case-sensitively to the proof'shtmclaim per RFC 9449 §4.3.:http_uri- the HTTP target URI of the request, including scheme and host. Query and fragment components are stripped before comparison so a client that signedhttps://api.example/xand the server-observedhttps://api.example/x?cb=1still match.
Optional opts
:access_token- the bearer/DPoP access token presented on the same request. If supplied, the proof MUST carry anathclaim whose value isbase64url(SHA-256(access_token))per RFC 9449 §4.3. If:access_tokenis omitted (e.g. the proof is attached to a token endpoint request, where no access token exists yet), theathclaim - if present - is returned but not constrained.:now-DateTimeor unix-seconds integer used as the clock reference. Defaults toDateTime.utc_now/0. Test-facing.:max_age_seconds- how far in the pastiatmay be. Default 60. A constant 60-second window into the future is also accepted to tolerate modest client-side clock skew.:replay_check- a two-arity function called with the proof's replay identity and the TTL (seconds) the store must remember it for, AFTER every other proof check has passed. Returns:okif the identity has not been seen, or{:error, :replay}if it has. The identity is an OPAQUE string (thereplay_keyin the success map: a fixed-length digest namespacing thejtiby the proof key - do NOT assume it is the rawjtior parse it). This records it inline, before the caller has verified the access token and itscnf.jktbinding - so a protected-resource or token pipeline should NOT use it; omit it, verify the token, then record the returnedreplay_key/replay_ttl(see the "Replay protection" section andAttesto.Plug.Authenticate). Use it only for a flow with no later binding step, or in test scaffolding.:nonce_check- a one-arity function called with the proof'snonceclaim (which may benil). Returns:okor{:error, :use_dpop_nonce}(RFC 9449 §8), the latter telling the caller to answer with a freshDPoP-Nonce. Omitted, no nonce is required. SeeAttesto.DPoP.NonceStore.
Returns
{:ok, %{jkt: ..., jti: ..., ath: ..., htm: ..., htu: ..., iat: ...}}on success.jktis the RFC 7638 SHA-256 thumbprint of the proof's embedded JWK; the caller compares it to the access token'scnf.jkt.{:error, reason}otherwise. See the module typespecs for the full error set.
The success map carries jkt (the RFC 7638 thumbprint of the proof key),
jti, iat, htm, htu, ath (or nil), and replay_ttl - the
acceptance window this verification derived, for a caller that claims the
jti itself rather than through :replay_check (as
Attesto.Plug.Authenticate does, to avoid claiming for a request whose
token has not yet verified). Keys may be added in a minor release, so
match the ones you need rather than the whole map.