Skip to main content

Attestation-Based Authentication and Authorization

This guide walks through wiring up wallet attestation end-to-end: a wallet authenticates to a credential issuer using a wallet-provider-signed attestation (WIA) plus a per-request proof-of-possession (PoP), with no pre-registered OAuth client_id anywhere in the flow. See Wallet Attestation for the concepts and security model this guide assumes.

Three components, three repos

A working deployment touches three independently-configured pieces: the wallet provider (issues WIAs), the issuer (verifies them and asks the PDP for a trust decision), and the wallet client (generates and attaches the attestation headers). This guide configures all three, in that order, then verifies the whole chain with a real PAR request.

Prerequisites​

  • A credential issuer with a reachable Go-Trust PDP — trust.pdp_url must be set for wallet attestation to activate at all.
  • A wallet provider capable of issuing WIAs (this guide uses go-wallet-backend's wallet_provider.wia support; any provider implementing draft-ietf-oauth-attestation-based-client-auth works).
  • A wallet client that can generate the attestation headers per flow (this guide uses wallet-frontend's pattern; native SDKs can follow the same shape).

Step 1 — Configure the wallet provider​

Enable WIA issuance and pick an identity format. Once signing keys are configured, wia.wallet_provider_uri is required (it is the expected aud of the WIA request-PoP). A minimal ietf configuration with JWKS-only trust:

wallet_provider:
private_key_path: /path/to/wallet-provider-key.pem
wia:
enabled: true
mode: "ietf" # see the two formats below
issuer: "https://wallet-provider.example.com" # required for mode: ietf
wallet_provider_uri: "https://wallet-provider.example.com"

and an etsi configuration, which additionally needs the certificate chain and the TS03 wallet identification:

wallet_provider:
private_key_path: /path/to/wallet-provider-key.pem
certificate_path: /path/to/wallet-provider-cert.pem # required for mode: etsi
wia:
enabled: true
mode: "etsi"
wallet_provider_uri: "https://wallet-provider.example.com"
wallet_name: "Example Wallet" # required for mode: etsi
wallet_version: "1.4.0" # required for mode: etsi

mode picks the identity format, and the two are mutually exclusive:

mode: "etsi" (default)mode: "ietf"
WIA header carriesAn x5c certificate chain, no issA kid, plus the iss claim. x5c is added only if a certificate_path is configured — omit it for pure JWKS trust
Relying party resolves the key fromThe embedded certificate, verified against the Trusted List for Wallet ProvidersThe provider's own JWKS, discovered from iss
Specified byEUDI ARF v3.0 / EC TS03 v1.5.2 / ETSI TS 119 472-3draft-ietf-oauth-attestation-based-client-auth (no ARF counterpart)
Use whenInteroperating with ARF-conformant PID/EAA providers — the only mode with a defined trust path under the EUDI specsA generic, non-EUDI OAuth ecosystem, or you want to avoid managing a cert chain
Also requirescertificate_path, wallet_name, wallet_version (TS03 §2.3.1)issuer

If you pick mode: "ietf", the wallet provider must publish its signing key somewhere the issuer's discovery can find it. go-wallet-backend does this automatically at two paths once issuer is set:

GET https://wallet-provider.example.com/.well-known/jwks.json
GET https://wallet-provider.example.com/.well-known/oauth-authorization-server

The second is RFC 8414 metadata wrapping the first — publish both if you're rolling your own provider, since relying parties differ in which discovery path they try (see Wallet Attestation for the full discovery chain).

Step 2 — Configure the issuer​

apigw:
trust:
pdp_url: "https://trust.example.com/pdp"
wallet_attestation:
enabled: true

That's the minimum. Optionally wallet_attestation.mode pins the accepted identity format: "etsi" requires an x5c chain, "ietf" requires iss and rejects a WIA that carries x5c (so the wallet provider must not have a certificate_path configured), and the default "" accepts either. wallet_attestation.policy is optional and defaults to "open" — any wallet provider the PDP trusts is authorized for any scope. Add it only if you need per-scope tiering by attestation strength (e.g. requiring hardware-backed attestation for a specific credential type):

apigw:
trust:
wallet_attestation:
enabled: true
policy:
rules:
- "(wallet (attestation_source ios_app_attest)(scope pid)(issuer *))"
# or load the rules from a file: rules_file: /path/to/rules.spocp
A PDP is required

Wallet attestation only activates when trust.pdp_url is set, and a PDP is required for production use of vc components. Running without a PDP is not supported: some things may work (allow-all trust, local did:key/did:jwk resolution), but resolution through the PDP (for example did:web) is unavailable and nothing is guaranteed. Use that mode for testing and development only.

Step 3 — Make the wallet provider trusted by the PDP​

The issuer's own config only says "check attestations." The PDP decides whether a specific wallet provider is trusted — this is the actual admission-control point. Simplest option, a whitelist registry:

registries:
whitelist:
enabled: true
lists:
wallet-providers:
- "https://wallet-provider.example.com"
actions:
wallet_provider: "wallet-providers"

(lists and actions can equally live in a separate file referenced by config_file:; the keys are the same either way. The action name must be wallet_provider with an underscore — that is the role string the issuer sends.)

For production, prefer OpenID Federation so wallet providers can be added/removed without touching the PDP's static config.

Step 4 — Generate the attestation on the wallet client​

Per flow, the client needs to generate and attach two things to the PAR/token request:

OAuth-Client-Attestation: <WIA JWT>
OAuth-Client-Attestation-PoP: <per-flow PoP JWT>

The WIA itself is obtained from the wallet provider (typically a challenge/response: fetch a nonce, sign it into a request-PoP, exchange for the WIA). Getting the claims right is where real integrations lose the most time — three values are easy to conflate:

ValueWhat it isGoes into
The wallet's own client_id for this flowFor an unregistered client, this is the wallet's redirect_uri (OID4VCI §7.1 convention) — not the credential issuer's URLThe WIA's sub claim, and the per-flow PoP's iss
The credential issuer's URLParsed from the credential offerThe per-flow PoP's aud — it's sent to the issuer
The wallet provider's URLWherever the WIA itself was requested fromThe request-PoP's aud — a separate JWT, sent to the provider, never to the issuer

Two of these being swapped are exactly the bugs a real integration (wallet-frontend) hit and fixed — see the troubleshooting table below for the exact symptoms.

Verifying it works​

Drive a real PAR request (through your wallet client, or by hand) and check for request_uri in the response — a bare /authorize?client_id=... redirect with no request_uri means PAR silently fell through to a different (or no) authentication path. Trace the request through the issuer's logs; each stage below logs distinctly, so you can tell exactly where a failing attempt stopped.

Troubleshooting​

These are the actual errors hit (in order) getting a real deployment working, each a genuinely different root cause:

SymptomRoot causeFix
Bare /authorize redirect, no request_uri, issuer logs show nothing attestation-related at allThe wallet client never attached the attestation headers for this transport/code pathConfirm the client's attestation generation runs for every transport it supports — it's easy to wire it into one code path and miss another
JWT signature verification failed: ... failed to discover JWKS for issuer ...: HTTP 404 (all four discovery paths)The wallet provider's iss doesn't resolve to any of the discovery documents the issuer tries — often because the provider only publishes a bare .well-known/jwks.json with no wrapping metadata documentPublish RFC 8414 metadata (.well-known/oauth-authorization-server) alongside the bare JWKS — see Step 1
wallet attestation subject does not match client_idThe WIA's sub was set to something other than the exact client_id the issuer resolves for this request (e.g. the credential issuer's own URL instead of the wallet's redirect_uri)Set the WIA's sub/client_id to the same value used as the OAuth client_id on the wire — see the table in Step 4
attestation PoP validation failed: PoP aud [...] does not contain expected audience [issuer URL]The per-flow PoP's aud was set to the wallet's own client_id instead of the credential issuer's URLThe per-flow PoP is sent to the issuer — its aud must be the issuer's URL, not the wallet's own identifier
[internal_server_error] credential type "..." has no data source configured (after PAR/authorize succeed)Not an attestation problem — the flow authenticated fine; this specific credential type just isn't wired to a data source on the issuerConfirm attestation is working by trying a different, already-configured credential type before debugging further