Standard advice for refresh tokens: rotate on every use, store hashed, set a short expiry. 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 race to rotate it next. Whoever loses the race gets a "token revoked" error — and the winner keeps the session.
From the server’s point of view, it just sees two valid requests seconds apart. No alarm, no signal, nothing.
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, treat it as evidence of compromise and invalidate the entire session.
Every token belongs to a family (FamilyId), shared across all rotations of a single login.
If a rotated token shows up again (outside a small grace window), you revoke the entire family:
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 it’s 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"); } You can also hook into it for observability (alerts, SIEM, etc.):
services.AddSingleton<IAuthEventSink, SlackAlertSink>(); FamilyId + index makes this a single bulk update.I ended up adding this to a small self-hosted auth library I’ve been working on (https://www.reddit.com/r/dotnet/comments/1shpady/selfhosted\_auth\_lib\_for\_net/)
Click to Open Code Editor