Refresh Token Rotation & Reuse Detection
Web developmentweb development / security / auth
The incident
A laptop gets stolen in March. The user changes their password that night. In July, your logs show that same person's account pulling data from an IP in another country — with a valid, freshly-issued access token.
Nobody "broke in". The thief still had a refresh token from March, and your auth server had been happily trading it for a new 15-minute access token every 15 minutes for four months.
Password change didn't help: it invalidated the password, not the token. Short access-token TTL didn't help either — a refresh token that never changes is a permanent key wearing a temporary key's clothes.
The fix: rotate on every use
Each refresh call returns a NEW refresh token and retires the old one.
POST /token {grant_type: refresh_token, refresh_token: RT_7}
→ {access_token: AT_8, refresh_token: RT_8} # RT_7 is now dead
Store per token: id, family_id, user_id, used_at, expires_at.
The real payoff: reuse detection
Rotation alone doesn't stop a thief — it makes theft loud.
Two parties now hold RT_7: the real user and the thief. Whoever refreshes second presents an already-used token. That's not a normal event — a healthy client never replays a token it already spent.
So when a used token is presented:
→ revoke the ENTIRE family (every descendant of that first login), not just that token.
Both sides get logged out, the legit user re-authenticates, the thief is out. You've converted a silent 4-month breach into one annoying re-login.
if token.used_at is not None:
revoke_family(token.family_id) # nuke the whole chain
raise Unauthorized
The gotcha that will page you at 2am
Ship naive rotation and you'll get a flood of "randomly logged out" tickets. Why:
The user has 3 tabs open. All three see a 401 at the same moment. All three POST RT_7 concurrently. One wins; the other two look exactly like an attacker — and you just killed the family of a completely innocent user. Same thing on flaky mobile networks: the client retries a refresh whose response never arrived.
Two defenses, use both:
1️⃣ Grace window — accept a used token for ~10–30s and return the SAME successor you already issued (cache the response keyed by the old token id). Idempotent refresh.
2️⃣ Single-flight on the client — one shared in-flight refresh promise; other callers await it instead of firing their own.
Numbers worth committing to
- Access token: 5–15 min, stateless JWT, never checked against a DB.
- Refresh token: 7–30 days idle expiry + a hard family cap (~90 days) so a long-lived session still ends.
- Grace window: 10–30s. Long enough for a retry, short enough that a thief can't live in it.
- Storage: refresh token in an HttpOnly + Secure + SameSite cookie, never localStorage.
The mental model shift
Access tokens buy you statelessness. Refresh tokens buy you revocability. If your refresh token isn't rotated and tracked server-side, you've given up revocability and kept nothing in return — you just have a very long-lived password with extra steps.
Your exercise
Open your app in two tabs, let the access token expire, then trigger a request in both at the same instant (throttle the network to make the race easy to hit).
Does one tab get logged out? If yes, you have rotation without a grace window — the exact bug that makes teams disable reuse detection instead of fixing it. Now implement single-flight refresh and re-run it.
Bonus: how would you tell a genuine stolen-token replay apart from a client bug that replays tokens? What would you log to know the difference at 2am?