Refresh-token rotation sounds simple: exchange token A, receive token B, invalidate A. Concurrency makes that sequence a security protocol.
Two browser tabs, workers, or retrying network clients can submit A almost simultaneously. Your server must distinguish an expected race from replay without leaving multiple valid descendants.
Model the token family
A (active)
├─ request 1 -> B (active)
└─ request 2 -> reject or bounded race response
reuse of A after the race window -> revoke family
Store only token hashes. A record needs a family ID, parent ID, status, issued time, replacement ID, and reuse time. The exchange that marks A consumed and creates B must be one transaction.
Run two requests together
This shell fixture intentionally sends the same fake refresh token twice:
body='grant_type=refresh_token&refresh_token=test-token-a'
curl -sS -o first.json -w '%{http_code}' \
-d "$body" https://localhost:8443/oauth/token > first.status &
curl -sS -o second.json -w '%{http_code}' \
-d "$body" https://localhost:8443/oauth/token > second.status &
wait
cat first.status second.status
Use a disposable authorization server and synthetic tokens. Never paste production refresh tokens into a fixture.
Four invariants
- At most one active child exists after both requests settle.
- A consumed parent cannot create another active child.
- Reuse outside a documented race window revokes or escalates the family.
- A client persists the new access and refresh tokens atomically; losing the rotated refresh token must fail closed and visibly.
Test three schedules: truly concurrent requests, a second request inside the allowed grace period, and delayed replay after that period. Record status codes and database transitions, not secret values.
OAuth's current security guidance recommends sender-constrained refresh tokens or rotation for public clients. Rotation is not merely returning a new string; the family and replay semantics are the control.
Common implementation failure
A read-then-write sequence without a row lock or compare-and-swap lets both requests observe A as active. Both create children, and the server has silently forked authority.
Prefer a conditional update such as:
UPDATE refresh_tokens
SET status = 'consumed'
WHERE token_hash = :hash AND status = 'active';
Continue only when exactly one row changed, in the same transaction that inserts the child.
The test does not prove the entire OAuth deployment is secure. It does turn one subtle race into a deterministic acceptance gate you can rerun after storage, retry, or session changes.
Top comments (0)