A real incident from the finance app I build solo. The security design
was textbook. That's exactly why it bit me.
The textbook setup
Short-lived access token, long-lived rotating refresh token. Every
time the client refreshes:
- It presents its refresh token.
- The server verifies it, revokes it, and issues a brand-new access + refresh pair.
- The old refresh token is now dead — single use.
And the part everyone recommends (OWASP calls it refresh-token reuse
detection): if a client ever presents a refresh token that's already
been revoked, treat it as theft. An attacker who stole a token and
replayed it would trip this. The safe response is scorched earth —
revoke every session for that user so both the attacker and the
victim get kicked and must sign in fresh.
This is correct. It catches a real attack. Ship it.
The incident
One evening my own app showed me a auth error over a €0.00 balance and
an "add your first account" screen — as if I'd never signed in. The
data was all there on the backend. Only the login was dead.
The audit log told the story. All day: healthy refreshes every ~15
minutes. Then, one refresh, the server saw an already-revoked token,
declared theft, and revoked all sessions. The "attacker"?
Every session in the chain carried the same device id. It was my
phone. My phone was the thief.
How a device robs itself
A same-device replay of its own just-rotated token has completely
benign causes, and they're all common:
- Lost response. The server rotated the token and committed, but the HTTP response never made it back (dropped connection, backgrounded app, dead tunnel). The client still holds the old token and retries it. To the server: a revoked token, replayed.
- A failed local write. The new token pair came back, but writing it to the keychain failed (or raced with app suspension). Next launch, the client presents the previous token.
- Two processes racing. My app and its widget extension both hit a 401 and both refresh with the same token. One wins and rotates it; the other arrives a beat later with what is now a revoked token.
None of these are theft. But "revoked token replayed" looked identical
to theft, so the nuke fired and I logged myself out everywhere.
The insight
Here's the thing I missed: for a same-device replay, the revoke-all
adds almost no security.
Reuse-detection defends against an attacker who exfiltrated a refresh
token and replays it from somewhere else. That "somewhere else" is
the signal. An attacker replaying from the same device, with the same
bound device id, already had full access to the device's storage — the
nuke doesn't save you there; they could just present the live token.
So the theft signal isn't "a revoked token was replayed." It's "a
revoked token was replayed from a different device than the one the
session is bound to."
The fix
Bind sessions to a device id, then scope the scorched-earth response to
the cases that are actually suspicious:
if (session.revokedAt) {
const sameDeviceBenignReplay =
session.deviceId !== UNBOUND &&
session.deviceId === presentedDeviceId &&
session.revokedReason === "rotated";
if (!sameDeviceBenignReplay) {
await revokeAllSessions(session.userId, "theft_detected");
}
throw new RefreshError("token_replayed"); // 401 either way
}
- Same device, replaying its own rotated token → plain 401. The client just re-authenticates its refresh normally or picks up the winner's new token. No nuke.
- Different device, or a non-rotation revocation (sign-out), or an unbound session we can't attribute → keep the full revoke-all. Real theft signals still get scorched earth.
Three supporting fixes made it robust:
- A cross-process refresh coordinator. The app and widget now go through a single-flight mutex (a file lock in a shared container + a notification), so only one refresh is ever in flight per device. The loser waits for the winner's token instead of racing it.
- Harden the token write. Check the keychain write status, retry once, and log loudly on failure — a silently failed write was one of the replay causes.
- Never turn a transient failure into a logout. A dropped network or a 5xx on the refresh endpoint must be retryable, not "your session is dead." Only a genuine 401/403 from the auth server means sign out.
Takeaways
- Rotating refresh tokens + reuse detection is still the right design. Keep it.
- But "reused token" is not the same as "stolen token." The stolen signal is reuse from an unexpected device. Bind sessions to a device and let that distinction gate your most destructive response.
- Make client refresh single-flight across every process that shares the credentials, or your own app becomes the attacker.
- Distinguish transient failures from auth rejections everywhere in the refresh path. Logging users out on a network blip is its own outage.
The uncomfortable lesson: a security control that's too eager is
indistinguishable from a bug to the person it locks out. Scope the
blast radius to the actual threat.
I'm building EveryPenny, a private,
multi-currency money tracker for iPhone. This one shipped a fix the
same day — solo means the postmortem and the patch are the same
afternoon. Ask me anything about the auth model in the comments.

Top comments (1)
"A security control that's too eager is indistinguishable from a bug to the person it locks out" — that sentence belongs on the wall of every team that ships a defense. From the user's side there's no difference between "we protected you" and "we broke it"; both are a door that won't open, and the intent behind it is invisible to the person standing outside.
What makes this more than a bug story is your threat-model catch: the same-device replay you were nuking was never the attack, because an attacker on the same bound device already had full access. So the control was paying its entire cost — locking out real users — on a case where it bought exactly zero security. That's the audit I now run on every defense I add: not "does it stop the bad thing," but "what does it cost on the good thing, and is the bad thing it stops even reachable here." A protection that only fires on benign causes is pure liability wearing a security badge.
And keeping the design while narrowing the trigger is the right call, which most postmortems get wrong — the lesson isn't "rotating tokens were a mistake," it's "the blast radius was aimed at the wrong axis." Cross-device replay deserves the scorched earth; a dropped response on one device deserves a retry. Same detector, completely different response, because the two look identical only if you ignore the one dimension that carries the actual risk. Nicely diagnosed, and shipped same day — respect.