Security model
OID4AC's threat model assumes a hostile agent, a hostile merchant, a hostile network, and a leaked token. None of these failure modes should let a charge complete without principal consent. The protocol's defences are layered so each individual leak still requires another to escalate.
Token theft is not enough
A leaked JWT-AT cannot be used by an attacker because the token is bound to
the agent's DPoP keypair via the cnf.jkt claim. Every protected
endpoint requires a fresh DPoP proof signed by the matching private key. See DPoP nonces and replay.
typ pinning defeats token confusion
JWT-ATs carry typ=at+jwt in the header (RFC 9068 §2.1). ID
tokens, mandate VCs, and DPoP proofs all use distinct typ values. A verifier that accepts a wrong-typed token by mistake will fail the
header check before reaching signature verification. See the JWT-AT claim set.
Algorithm whitelist defeats algorithm confusion
Every JWT and JWS verifier in the system rejects alg=none and
enforces an explicit allow-list per surface. Mandatory tests
(test_jwt_alg_none_rejected, test_jwt_rs256_rejected_outside_legacy, test_dpop_proof_hmac_rejected) gate every release. See the algorithm whitelist.
Replay defence
| Layer | Mechanism | Storage |
|---|---|---|
| DPoP proof replay | (jkt, jti) tuple; 60-second window | Redis set oid4ac:dpop_jti:{jkt} |
| DPoP nonce pre-generation | Server-issued DPoP-Nonce; rotated on every error and randomly on success | Redis oid4ac:nonce:{id} TTL 90 s |
| Authorization code replay | Single-use with cascade revoke on second use | Redis oid4ac:authz_code:{code} TTL 60 s |
| Refresh token replay | Rotation on every exchange; family-wide revoke on second use of a rotated RT | Redis oid4ac:rt:{rt_id} and family set |
| KB-JWT presentation replay | Per-merchant single-use nonce of the form sha256(merchant_nonce || offer_digest) | Merchant-side nonce log |
| client_assertion replay | jti single-use within the assertion's exp | Redis oid4ac_client_assertion_jti_seen |
Audience binding
Every JWT-AT carries aud=<merchant origin> per
RFC 8707. A merchant rejects any token whose aud does not match
its own origin. The SD-JWT VC mandate carries the same aud; the
KB-JWT binds the presentation to the same origin. Cross-merchant token
reuse fails at three layers. See resource indicators and audience.
Issuer pinning
Every authorization response carries iss=https://as.oid4pay.com per RFC 9207. Mix-up attacks where an attacker substitutes a hostile AS are
detected by the agent / wallet on the redirect leg. The Wallet Portal and
every SDK enforce this.
Separation of duties
| Concern | Key material |
|---|---|
| Agent DPoP proofs | Distinct private key, registered via DCR as dpop_jwk |
| Agent client assertions | Distinct private key, registered via DCR as private_key_jwt_jwk |
| AS JWT-AT signing | Ed25519, rotated 90 days; kid in JWKS history |
| AS mandate signing | Same Ed25519 key as JWT-AT in version 1; separating into a distinct kid is on the roadmap |
| Audit chain head signing | Distinct Ed25519, never used elsewhere |
Key rotation
Ed25519 signing keys rotate on a 90-day cadence; the JWKS publishes both the current and previous kid for the overlap window. JWT verifiers MUST honour kid lookup, not just current-key check. A documented key-rotation runbook describes the staged rollout.
Mandate revocation
Every mandate VC carries a credentialStatus claim pointing at a
W3C VC Status List 2021 endpoint at /oauth/status-list. The AS
publishes the list every 60 seconds. Merchants fetch the bitmap once per 5
minutes and look up the index locally. See the W3C VC Status List.
Audit chain
Every SSF event is wrapped in the audit-chain entry envelope, sequence-numbered per tenant, and prev-hash chained. The chain head is signed every hour; rewriting a past entry breaks the chain at the next signing. WORM exports land in S3 Object Lock for SOC2 (7y) and eIDAS (10y) retention classes. See the audit chain entry envelope.
What an attacker still cannot do
- Steal a JWT-AT and replay it: rejected by DPoP
cnf.jktcheck. - Steal a JWT-AT and use it cross-merchant: rejected by
audcheck. - Forge a mandate: requires the AS signing key.
- Forge an Offer: requires the merchant's signing key.
- Replay a charge: rejected by KB-JWT nonce single-use.
- Rewrite an audit entry: detected by prev-hash break at the next chain-head signing.
- Strip the four-signature pack: a dispute fails closed; the merchant is liable.