Standard advice for refresh tokens is pretty straightforward:
- rotate on every use
- store them hashed
- keep expiry short
Done, right?
Not quite.
Rotation alone does nothing against token theft. If malware or XSS lifts a refresh token from a legit client, the attacker and the client effectively race to rotate it.
- whoever loses the race gets a "token revoked" error
- whoever wins keeps the session alive
From the server’s point of view, it just sees two valid requests seconds apart.
No alarm.
No signal.
Nothing.
---
What’s missing
The missing piece is what OAuth 2.0 Security BCP §4.14 calls:
> Refresh token reuse detection
If a token that was already rotated is presented again, you treat it as evidence of compromise and invalidate the entire session.
---
The core idea
Every refresh token belongs to a family (
FamilyId) — all tokens derived from a single login share it. If a rotated token shows up again (outside a small grace window), you revoke the entire family: the attacker is locked out the legit user is forced to re-authenticate the session is no longer silently compromised
if (stored.ReplacedByTokenHash is not null && stored.RevokedAtUtc.HasValue)
{
var withinGrace = stored.RevokedAtUtc.Value.AddSeconds(graceSeconds) > DateTime.UtcNow;
if (withinGrace)
return Fail("token_recently_rotated"); // benign race (SPA tabs, retries)
await RevokeFamilyAsync(stored.FamilyId, ip, reason: "reuse_detected");
return Fail("token_reuse_detected");
}
Client-side impact
From the client perspective, this is just one extra branch:
if (error.code === "token_reuse_detected") {
// "You've been signed out for security reasons. Please log in again."
router.push("/login?reason=compromised");
}
Observability (optional but useful)
If you want visibility into these events (alerts, logging, SIEM), you can expose a hook:
services.AddSingleton<IAuthEventSink, SlackAlertSink>();
The tricky parts
- Race vs theft look identical Two requests with the same token arrive. one is legitimate one might be malicious The only difference is timing. Trade-off: grace window too small → false positives (bad UX) grace window too large → bigger attack window ~30 seconds worked well in practice.
- Revoking the whole chain
On reuse, you must invalidate all still-active tokens from that session.
Using a
FamilyId+ index makes this a single bulk operation. - Concurrency is common This was more frequent than expected: multi-tab SPAs retry logic mobile reconnects Without a grace window, even normal usage can trigger false positives.
Final thoughts
I originally assumed refresh token rotation was “enough”. It isn’t.
Without reuse detection, the server has no way to distinguish between normal rotation and a replayed (potentially stolen) token — and learns nothing from it. Adding reuse detection turned out to be a relatively small change, but it closes a much more serious gap than I expected.
I ended up implementing this in a small self-hosted auth library I’ve been working on. Curious how others handle this — especially around race conditions and trade-offs between grace windows vs optimistic concurrency.
Implementation is here: https://github.com/KiwiDevelopment/KiwiAuth
Top comments (0)