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), emitoid4ac.security.code_replaySSF event, page on-call. oid4ac_authz_code_replay_totalmetric.
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:
- Returns
invalid_grant. - Adds the originally-issued AT's
jtito the AS revocation set; subsequent presentations are rejected. - Marks the originally-issued RT family as revoked.
- Marks the mandate the family was issued under as
credentialStatus.revoked. - Emits
oid4ac.security.code_replaySSF event. - 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.