devto 2026-07-23 원문 보기 ↗
A real incident from the finance app I build solo. The security design
was textbook. That's exactly why it bit me.
Short-lived access token, long-lived rotating refresh token. Every
time the client refreshes:
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.
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.
A same-device replay of its own just-rotated token has completely
benign causes, and they're all common:
None of these are theft. But "revoked token replayed" looked identical
to theft, so the nuke fired and I logged myself out everywhere.
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."
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
}
Three supporting fixes made it robust:
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.