tags: security, redis, nodejs, jwt
Refresh token rotation is easy to describe and annoying to get right. The idea: every time a refresh token is used, kill it and hand back a new one. Simple.
The part most write-ups skip: what do you do when someone uses a refresh token that's already been rotated away?
That's not an edge case you can ignore. If your client already exchanged token A for token B, and a request shows up later using token A, one of two things happened:
- A retried request or race condition on your own client (rare, but happens)
- Someone else has a copy of token A — the legitimate session already moved on, so this is theft
You can't tell which from the request alone. So the safe move is to treat it as theft: kill the entire session lineage, not just the one bad token.
The pattern: token families
Instead of tracking individual refresh tokens, track families. A family is created at login. Every rotation within that family updates a single pointer in Redis: "this is the currently valid token ID for this family."
When a refresh request comes in:
- If the presented token ID matches the family's current pointer → legitimate rotation. Issue a new token ID, update the pointer.
- If it doesn't match → this token was already superseded. Reuse detected. Delete the family outright.
That second branch is the whole point. One Redis key, one comparison, and you get theft detection almost for free.
const current = JSON.parse(await redis.get(familyKey(familyId)));
if (current.tokenId !== presentedTokenId) {
// presented token isn't the current one — it was already rotated away
await redis.del(familyKey(familyId));
return res.status(401).json({ error: 'Refresh token reuse detected' });
}
The Redis footprint stays constant per session regardless of how many times it refreshes — you're storing "what's valid right now," not a growing history.
Why Redis and not just a DB flag
You could do this in Postgres with a revoked_at column. Redis just makes the TTL bookkeeping free: set the family key's expiry to match the refresh token's own lifetime, and stale families clean themselves up. No cron job, no orphaned rows.
Full write-up + runnable code
The full breakdown — including the login flow, what happens on legitimate logout, and why family-level revocation beats token-level revocation for this specific attack — is here:
Detecting Refresh Token Reuse with Redis →
And the actual runnable example (Express + ioredis + Docker Compose, clone and curl it yourself):
github.com/polasamy-eng/devsaas-devops-examples — see refresh-token-reuse-detection/
If you're implementing this and hit a case that doesn't fit — multi-device logout, mobile clients that retry on flaky networks, whatever — I'd genuinely like to hear it. Most of what's written about this pattern glosses over exactly those cases.
Top comments (0)