Skip to main content

Authorization code single-use and cascade revoke (RFC 9700 §2.3)

The rule

  • Auth code stored in Redis at oid4ac:authz_code:<code> with TTL 60s.
  • Atomic CAS at redemption: GETDEL oid4ac:authz_code:<code>. First call returns the code data; second call returns nil (invalid_grant).
  • On second-redemption: cascade-revoke the AT (add to oid4ac:revoked_at:<jti> set), revoke any RT (mark the family revoked per the refresh token contract), emit oid4ac.security.code_replay SSF event, page on-call.
  • oid4ac_authz_code_replay_total metric.

Why atomic CAS

A non-atomic check-then-delete is a TOCTOU race: two simultaneous requests presenting the same code can both win the check before either delete lands. Redis GETDEL is atomic; either both reads succeed (bug) or exactly one does (correct). The OID4Pay AS uses GETDEL as the canonical pattern.

Cascade semantics

A second use of a redeemed code is taken as evidence that the original redemption was intercepted. The AS:

  1. Returns invalid_grant.
  2. Adds the originally-issued AT's jti to the AS revocation set; subsequent presentations are rejected.
  3. Marks the originally-issued RT family as revoked.
  4. Marks the mandate the family was issued under as credentialStatus.revoked.
  5. Emits oid4ac.security.code_replay SSF event.
  6. Pages on-call.

Wire shape (the second-use return)

HTTP/1.1 400 Bad Request
Content-Type: application/json

{
  "error": "invalid_grant",
  "error_description": "authorization code already used"
}

Worked example

# First exchange succeeds.
$ curl -sS https://as.oid4pay.com/oauth/token \
    -d grant_type=authorization_code \
    -d code=$CODE \
    -d code_verifier=$VERIFIER ...
{"access_token":"...","refresh_token":"...","expires_in":300, ...}

# Replay attempt fails AND triggers the cascade.
$ curl -sS https://as.oid4pay.com/oauth/token \
    -d grant_type=authorization_code \
    -d code=$CODE \
    -d code_verifier=$VERIFIER ...
{"error":"invalid_grant","error_description":"authorization code already used"}

# The originally-issued AT is now rejected too.
$ curl -sS -H "Authorization: DPoP $AT" \
    https://shop.example.com/api/charge ...
{"error":"invalid_token","error_description":"token revoked"}

TTL choice

60 seconds is enough for a redirect leg + a token exchange under normal conditions and short enough that brute-forcing the 256-bit code space is infeasible. The window is also short enough to keep the Redis footprint trivial at any realistic concurrent-PAR load.