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, and EdDSA. 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. - If the caller supplies the
:replay_checkopt, the verifier invokes it with the proof'sjtiAND the TTL the cache must remember it for (the acceptance window:max_age_seconds+ future skew), AFTER every other check has passed (so an attacker cannot fill the cache with proofs that would have failed anyway). Deriving the TTL from the verifier's age policy keeps the cache from forgetting ajtiwhile the proof is still acceptable. The callback returns:okor{:error, :replay}.Attesto.DPoP.ReplayCacheprovides a default ETS-backed implementation (check_and_record/2).
Protected-resource pipelines MUST pass :replay_check. Leaving it out
is acceptable only in test scaffolding and at the token endpoint on
first use of a proof (the endpoint records the jti itself).
Summary
Functions
The list of JOSE alg values accepted on a DPoP proof's protected
header.
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 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.
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 5-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'sjtiand the TTL (seconds) the store must remember it for, AFTER every other check has passed. Returns:okif thejtihas not been seen, or{:error, :replay}if it has. Required by protected-resource pipelines; pass&Attesto.DPoP.ReplayCache.check_and_record/2. Omit only 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.