HAMZA KAREEM / CONCEPTS hamzawi.dev

Lesson 732 min read

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?