← 목록

My app's anti-theft feature locked every user out

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.

The textbook setup

Short-lived access token, long-lived rotating refresh token. Every
time the client refreshes:

  1. It presents its refresh token.
  2. The server verifies it, revokes it, and issues a brand-new access + refresh pair.
  3. 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:

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
}

Three supporting fixes made it robust:

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

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.