OAuth 2.0 Attestation-Based Client Authentication
(draft-ietf-oauth-attestation-based-client-auth-10, 2026-07-06), the
"Wallet Attestation" client authentication method OID4VCI recommends for
native-app Wallets in place of private_key_jwt/mTLS.
A Wallet Provider (the Client Attester) issues its Wallet a Client
Attestation JWT (typ oauth-client-attestation+jwt) binding the
Wallet/Client Instance's public key into its cnf claim. To authenticate a
request, the Client Instance additionally presents a Client Attestation
PoP JWT (typ oauth-client-attestation-pop+jwt) signed by that
instance key, proving possession of it to this Authorization or Resource
Server.
verify/3 verifies both JWTs together per the draft's §7
"Verification and Processing" rules and returns the proven Client Instance
key. The host owns the trusted Wallet Provider key material and any
Challenge issuance/tracking; this module only performs the checks the
draft prescribes. Conn-free and fail-closed.
Client Attestation JWT (typ=oauth-client-attestation+jwt, draft §4)
sub- REQUIRED. The OAuthclient_idof the Wallet instance.exp- REQUIRED. Expiration time; rejected once passed.cnf- REQUIRED. A{"jwk" => <public JWK>}(RFC 7800) confirmation key - the Client Instance Key used to sign the PoP JWT.iat- OPTIONAL.
The draft version implemented here carries no aud on the Client
Attestation JWT itself (earlier drafts did; it was removed) - the
receiving server's identity is asserted by the PoP JWT's aud instead.
Trust in the signer (the Wallet Provider / Client Attester) is
out-of-band, per host-supplied trusted keys; the draft leaves the
discovery mechanism (PKI, kid+jku, pre-shared metadata) unspecified.
Client Attestation PoP JWT (typ=oauth-client-attestation-pop+jwt, draft §5.1)
aud- REQUIRED. This Authorization Server's issuer identifier URL (RFC 8414) or, for a Resource Server, its resource identifier URL (RFC 9728). Single-valued; a Client Attestation PoP JWT targets one audience only.jti- REQUIRED. A unique identifier the caller may use for its own replay tracking (see:replay_check).iat- REQUIRED. Freshness is checked against:max_age_seconds.challenge- OPTIONAL. Echoes a server-issued Challenge (draft §6); checked when the caller supplies:expected_challenge.
It MUST be signed by the private half of the Client Attestation's cnf
key - this module verifies exactly that.
Summary
Functions
Verify a Client Attestation JWT together with its Client Attestation PoP JWT and return the proven Client Instance key.
Types
@type replay_check_fun() :: (String.t(), pos_integer() -> :ok | {:error, :replay})
@type verified() :: %{ instance_key: instance_key(), attestation_claims: map(), pop_claims: map(), replay_key: String.t(), replay_ttl: pos_integer() }
@type verify_error() ::
:invalid_attestation
| :invalid_typ
| :invalid_alg
| :unsupported_critical_header
| :invalid_signature
| :expired
| :invalid_client_id
| :missing_cnf
| :invalid_cnf
| :invalid_pop
| :invalid_pop_typ
| :invalid_pop_alg
| :unsupported_pop_critical_header
| :invalid_pop_signature
| :invalid_pop_audience
| :invalid_pop_challenge
| :missing_pop_jti
| :invalid_pop_jti
| :missing_pop_iat
| :invalid_pop_iat
| :pop_expired
| :replay
@type verify_opts() :: [ trusted_wallet_provider_jwks: map() | [map()], audience: String.t(), client_id: String.t(), expected_challenge: String.t(), now: DateTime.t() | non_neg_integer(), max_age_seconds: pos_integer(), accepted_algs: [Attesto.SigningAlg.alg()], enforce_fapi_alg_policy: boolean(), replay_check: replay_check_fun() | nil ]
Functions
@spec verify(String.t(), String.t(), verify_opts()) :: {:ok, verified()} | {:error, verify_error()}
Verify a Client Attestation JWT together with its Client Attestation PoP JWT and return the proven Client Instance key.
Required opts
:trusted_wallet_provider_jwks- an RFC 7517 JWK Set, a single public JWK map, or a list of public JWK maps the Client Attestation JWT's signature is checked against. Establishing which Wallet Provider(s) to trust is the host's responsibility (draft §7.1 leaves this out of scope).:audience- this Authorization/Resource Server's own identifier; the PoP JWT'saudMUST equal it exactly.
Optional opts
:client_id- when set, the Client Attestation'ssubMUST equal it.:expected_challenge- a Challenge (draft §6) previously issued to the client; when set, the PoP'schallengeclaim MUST match it. The Challenge is a server-issued, non-secret freshness token (visible on the wire already), so this is a plain equality check, matchingAttesto.CredentialProof'sc_noncecheck.:now- clock reference (DateTime or unix seconds).:max_age_seconds- how far in the past the PoP'siatmay be. Default 300.:accepted_algs- JWS algorithms accepted for both JWTs' signatures. Defaults toAttesto.SigningAlg.fapi_algs/0.:enforce_fapi_alg_policy- additionally enforce the FAPI RSA modulus / Edwards curve restrictions on the Client Attestation signer's key. Defaults totruewhen:accepted_algsis omitted,falseotherwise (matchesAttesto.ClientAssertion.verify/5).:replay_check- a 2-arity function(replay_key, ttl_seconds) -> :ok | {:error, :replay}, called after every other PoP check passes.replay_keyis a fixed-length digest namespacing thejtiby the Client Instance Key's thumbprint - do not assume it is the rawjti. Omitted, no replay check is performed inline; the caller may instead record the returnedreplay_key/replay_ttlitself after any later binding step (seeAttesto.DPoP's "Replay protection" for why that order matters when there is one).
Returns {:ok, %{instance_key: %{jwk:, jkt:}, attestation_claims:, pop_claims:, replay_key:, replay_ttl:}}.