DEV Community

Cover image for If your refresh token gets stolen, rotation alone won't save you — here's what does
Dmytro
Dmytro

Posted on

If your refresh token gets stolen, rotation alone won't save you — here's what does

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");
}
Enter fullscreen mode Exit fullscreen mode

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");
}
Enter fullscreen mode Exit fullscreen mode

Observability (optional but useful)
If you want visibility into these events (alerts, logging, SIEM), you can expose a hook:

services.AddSingleton<IAuthEventSink, SlackAlertSink>();
Enter fullscreen mode Exit fullscreen mode

The tricky parts

  1. 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.
  2. 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.
  3. 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)