ASVS 5.0 V10.4.2 (Level 1) says an authorization code can be redeemed for tokens exactly once. If the same code shows up in a second valid token request, the authorization server must reject it and revoke every token that was already issued from that code.
The requirement applies to anyone who runs an OAuth authorization server, whether that is a product like Keycloak or a service you wrote yourself.
Why it matters
An authorization code travels through the browser. It lands in a redirect URL, which means it can end up in browser history, proxy logs, a Referer header, or a screenshot someone pastes into a ticket. If a code could be redeemed twice, anyone who saw the URL later could trade it for a fresh access token.
Single use turns a leaked code into a race. The legitimate client redeems it within seconds. After that, the code is useless to everyone else.
The second half of the requirement matters just as much. If a code is replayed, you cannot tell which party is the real client. The safe response is to assume the first exchange was the attacker and kill the tokens it produced. RFC 6749 section 4.1.2 recommends exactly this behavior.
How to implement it
The core rule is that checking a code and consuming it must be one atomic step. A read followed by a write leaves a window where two parallel requests both pass the check.
Here is a Python and PostgreSQL sketch. The code row stores a link to the grant, so a replay can find and revoke the tokens.
import psycopg
CONSUME_SQL = """
UPDATE auth_codes
SET used_at = now()
WHERE code_hash = %s
AND used_at IS NULL
AND expires_at > now()
RETURNING grant_id, client_id, redirect_uri, code_challenge
"""
def redeem_code(conn, code_hash, client_id, redirect_uri):
with conn.transaction():
row = conn.execute(CONSUME_SQL, (code_hash,)).fetchone()
if row is None:
# Either unknown, expired, or already used.
reused = conn.execute(
"SELECT grant_id FROM auth_codes "
"WHERE code_hash = %s AND used_at IS NOT NULL",
(code_hash,),
).fetchone()
if reused:
revoke_grant(conn, reused[0])
raise InvalidGrant()
grant_id, bound_client, bound_uri, challenge = row
if bound_client != client_id or bound_uri != redirect_uri:
raise InvalidGrant()
return grant_id, challenge
def revoke_grant(conn, grant_id):
conn.execute("UPDATE access_tokens SET revoked_at = now() "
"WHERE grant_id = %s AND revoked_at IS NULL", (grant_id,))
conn.execute("UPDATE refresh_tokens SET revoked_at = now() "
"WHERE grant_id = %s AND revoked_at IS NULL", (grant_id,))
A few details worth copying:
- Store a hash of the code, not the code itself, so a database leak does not hand out live codes.
- Keep used rows for at least the code lifetime plus a margin. If you delete them at once, a replay looks like an unknown code and you cannot revoke anything.
- Return the same
invalid_granterror in every failure case so callers learn nothing about which condition failed. - If you use a cache such as Redis instead of SQL, use an atomic operation like
GETDELor a Lua script, not GET then DEL.
Handling the distributed case
Single use gets harder once you run several instances. An in-memory set on each node does not work, because the replay may land on a different node. Keep the used flag in the shared store that already holds your codes, and make the conditional update the only path to success.
If you use a cache for codes, a Lua script keeps the check and the delete together:
local v = redis.call("GET", KEYS[1])
if not v then return nil end
redis.call("DEL", KEYS[1])
redis.call("SET", KEYS[2], v, "EX", ARGV[1])
return v
The second key keeps a short-lived tombstone, so a replay can be recognized as a replay instead of an unknown code. Set the tombstone lifetime to at least the code lifetime.
Logging the event
A replayed code is a security signal. Log the client, the grant, the source address and the time, and alert on repeats from the same client. A burst of replays usually means a client bug or an active attack, and both deserve a look.
How to test it
- Run a normal authorization code flow and capture the code from the redirect.
- Send the token request once. Confirm you get an access token and, if applicable, a refresh token.
- Send the identical token request again with curl. Expect HTTP 400 with
invalid_grant. - Use the access token from step 2 against a protected API. Expect 401, because the replay should have revoked it.
- Try the refresh token from step 2. Expect it to fail too.
For the race, fire two token requests with the same code at the same moment, for example with xargs -P 2 over curl. Exactly one should succeed, and the grant should end up revoked after the loser is processed.
Automating the check
Put the replay test in your integration suite so it runs on every release. A small pytest case is enough:
def test_code_is_single_use(client, oauth):
code = oauth.authorize_and_get_code()
first = client.post("/token", data=oauth.token_params(code))
assert first.status_code == 200
second = client.post("/token", data=oauth.token_params(code))
assert second.status_code == 400
assert second.json()["error"] == "invalid_grant"
api = client.get("/api/me", headers={"Authorization": "Bearer " + first.json()["access_token"]})
assert api.status_code == 401
Add a second test that launches the two token requests in parallel threads and asserts that exactly one returns 200.
Common mistakes
- Rejecting the replay but leaving the first tokens alive. That satisfies half the sentence.
- Marking the code used after the token response is built. A crash or a parallel request in between creates a double spend.
- Letting codes live in a stateless signed blob with no server side record. You cannot enforce single use without state.
- Skipping the client and redirect URI binding check, so one client can redeem another client's code.
Quick check
Why must the check and the consume step be atomic?
Two parallel requests can both read the code as unused before either marks it used. An atomic conditional update lets only one of them win.
What should happen to tokens issued from a code that is later replayed?
They should be revoked. A replay suggests the code leaked, and you cannot tell who holds the legitimate tokens.