Why Storing Every Refresh Token Is the Wrong Default
The naive way to detect refresh token reuse is to store every token ever issued in a database, mark it 'used' after rotation, and reject anything that shows up twice. It works, technically. It also means your token table grows forever, needs a cleanup job, and every refresh request becomes a write-heavy database transaction competing with the rest of your app's traffic.
You don't need a record of every token. You need a record of the current valid token per session, and a way to tell atomically whether the token just presented matches it. That's a much smaller data structure, and Redis is built for exactly this kind of check-and-swap operation.
The pattern that makes this work is called a token family.
Token Families, Not Token Logs
Instead of tracking individual tokens, you track a family: one logical session, identified by a family_id, that persists across every rotation. Each time the refresh token rotates, you're not creating a new record — you're updating the same one.
A Redis hash works well here:
HSET family:{family_id} jti "<current-jti>" user_id "<uid>" device "<fingerprint>" gen "3"
EXPIRE family:{family_id} <absolute_session_ttl_seconds>
On login, you create the family with the first token's jti. On every refresh, you overwrite jti in place. The key count in Redis scales with active sessions, not with tokens ever issued. A user who refreshes their token 500 times over a month still occupies exactly one hash key.
For the 'log out everywhere' feature, keep a secondary index per user:
SADD user_sessions:{user_id} {family_id}
When you need to kill all sessions for a user, you iterate that set and flip each family to a revoked state — no table scan, no per-token deletion.
The Race Condition Everyone Skips Over
Here's the part most guides gloss over with 'flag it as suspicious and revoke.' The actual failure mode is a race condition, and it's worth walking through slowly because it's where naive implementations quietly break.
Say a refresh token is stolen. The attacker and the legitimate user both hit /refresh with the same token, within milliseconds of each other. If your check is a plain read-then-write:
- Request A reads
jtifrom Redis — matches the presented token. - Request B reads
jtifrom Redis — also matches, because A hasn't written yet. - Both requests conclude the token is valid.
- Both issue new refresh tokens and update the family.
Now you have two valid sessions forked from one token, and no reuse was ever detected. This is the exact scenario reuse detection exists to catch, and a check-then-set pattern misses it completely under concurrency. It's the same class of problem you'd hit designing distributed rate limiting across multiple API servers with Redis — read-then-write across a network round trip is never safe when two clients can race.
Fixing It With a Lua Script
Redis executes Lua scripts atomically — no other command runs in between, even across multiple Redis clients hitting the same key. This turns the read-compare-write into one indivisible operation instead of three separate round trips.
-- KEYS[1] = family key
-- ARGV[1] = presented jti
-- ARGV[2] = new jti to write on success
-- ARGV[3] = ttl in seconds
local current = redis.call('HGET', KEYS[1], 'jti')
if current == false then
return 'NOT_FOUND'
end
if current == 'REVOKED' then
return 'REVOKED'
end
if current ~= ARGV[1] then
redis.call('HSET', KEYS[1], 'jti', 'REVOKED')
redis.call('EXPIRE', KEYS[1], ARGV[3])
return 'REUSE_DETECTED'
end
redis.call('HSET', KEYS[1], 'jti', ARGV[2])
redis.call('EXPIRE', KEYS[1], ARGV[3])
return 'OK'
Call it from Python with register_script and EVALSHA so the script body isn't re-sent on every call:
rotate_script = r.register_script(LUA_SOURCE)
result = rotate_script(
keys=[f"family:{family_id}"],
args=[presented_jti, new_jti, absolute_ttl_seconds]
)
With this, request A and request B from the race above no longer both succeed. Redis processes one Lua call fully before starting the next, even if they arrive within the same millisecond. Whichever request runs second sees a jti that no longer matches — because the first request already overwrote it — and gets REUSE_DETECTED, which flips the family to REVOKED immediately.
An alternative is WATCH/MULTI/EXEC with optimistic retry, but that requires a retry loop on conflict and adds latency under contention. For a single-key check-and-swap like this, a Lua script is simpler and cheaper — one round trip, guaranteed atomicity, no retry logic to get wrong.
The False Positive Nobody Talks About
There's a legitimate case that looks identical to an attack: a flaky network causes the client to retry the same refresh request. The first request succeeds and rotates the token server-side, but the client never got the response, so it retries with the now-stale token a second later.
If you revoke on every mismatch with zero tolerance, you'll log real users out for something that isn't malicious at all. A common mitigation is to keep a short grace record of the previous jti alongside a timestamp:
HSET family:{family_id} jti "<new>" prev_jti "<old>" prev_ts "<unix_time>"
In the Lua script, if the presented token matches prev_jti and the rotation happened within a small window — a few seconds, matching your client's HTTP retry timeout — you can safely replay the same response instead of treating it as an attack. Anything outside that window, or a mismatch against both fields, is a real reuse event and gets the full revocation treatment.
This window needs to be tight. Too generous, and you've reopened the exact race condition you just closed.
Choosing TTLs Correctly
Set the family key's TTL to the absolute maximum lifetime of the session — not a sliding window that resets on every refresh. If you reset the TTL every time, a stolen token that gets refreshed regularly by an attacker will keep the family alive indefinitely, defeating the point of an expiry policy at all.
A reasonable pattern:
- Refresh token absolute lifetime: 30 days, fixed from creation
- Access token lifetime: 5–15 minutes, short enough that revocation propagation lag doesn't matter much
- Revoked family tombstone: kept for the remaining TTL, not deleted immediately, so late-arriving duplicate requests still see
REVOKEDinstead ofNOT_FOUND
That last point matters more than it looks. If you DEL the key the instant reuse is detected, a slightly delayed duplicate request finds nothing, and your code has to decide whether 'not found' means expired-and-fine or revoked-and-dangerous. Leaving a REVOKED tombstone in place removes that ambiguity entirely.
Where This Connects to Access Token Revocation
Detecting reuse on the refresh token is only half the job. Once a family is marked REVOKED, any access tokens already issued under it are still valid until they expire naturally, unless you're checking a blacklist on every request. If you need that access token to die immediately too, you'll want the JTI-based blacklist pattern covered in how to revoke a JWT before it expires running alongside this family check — one handles the long-lived refresh token, the other handles the short-lived access token.
Common Mistakes to Avoid
- Check-then-set without atomicity. A plain
GETfollowed by a plainSETfrom application code always has a race window, no matter how fast Redis is. - No device or user binding on the family. If a
family_idisn't tied touser_id, a bug in family generation can let one user's rotation silently affect another's key. - Deleting the key on revocation instead of tombstoning it. This creates ambiguity for delayed duplicate requests and makes debugging incidents harder.
- Sliding TTLs on refresh. This lets an attacker who successfully rotates a stolen token keep the session alive forever.
- No grace window for legitimate retries, or a grace window that's too generous and reopens the race condition.
Verifying It Actually Works
Don't trust this by reading the code — force the race and watch what happens. Fire two concurrent requests at your refresh endpoint with the same token using something like hey or a small async script, and confirm exactly one succeeds while the other returns REUSE_DETECTED. Then check redis-cli HGETALL family:{id} and confirm the value is REVOKED, not a valid jti.
Separately, run a load test where a single client rotates tokens repeatedly over an hour and confirm your Redis key count for that user's session doesn't grow — you should see the same hash key updated in place, not a new key per rotation. If key count climbs with every refresh, something in your implementation is still creating per-token records instead of updating the family.## Try It Yourself
The Redis logic above is simplified for explanation. If you want to see it as a full, runnable Express service — login, rotation, reuse detection, and the family-revocation flow — the complete code is open source:
github.com/polasamy-eng/devsaas-devops-examples (see refresh-token-reuse-detection/)
Clone it, run docker compose up, and hit the endpoints with curl to watch the reuse detection trigger in real time.