Security7 min read
Refresh tokens that notice they have been stolen
Four ways into the same account, one session model behind them, and a refresh token that rotates. The useful part is not the rotation. It is what rotation leaves behind.
Four doors, one room
The game can be entered four different ways, and every one of them had grown its own idea of what a session was. The first decision was not cryptographic. It was that four doors may differ, but they open into one room: one session model, one way to end it, one place that decides whether a request is still you.
Passwords, where they exist, are hashed with argon2id. That part is unremarkable, and it should be. The interesting work was in what happens after the login succeeds.
Rotation, and what it leaves behind
The refresh token is host-only, is never readable from JavaScript, and is replaced every time it is used. Rotation alone is table stakes. The part worth the trouble is that the retired token is not simply deleted: its fingerprint is archived.
An archive turns a dead token from garbage into evidence. Deleting it throws away the only thing that could tell you that someone else kept a copy.
A token that merely expired tells you nothing. A token that was replaced, and then came back, tells you that two parties hold the same secret.
Reading a replay as theft
So the rule is short. A live token refreshes normally. An unknown token is refused. A token that matches something in the archive is not an error to be logged and shrugged at: it is a replay, and the only safe reading of a replay is that the owner is not the only one holding it.
const retired = await archive.find(fingerprint);
if (retired) {
await sessions.revokeAll(retired.ownerId); // replay, not expiry
}Every session that owner holds ends at once, on every device. It is deliberately blunt. The alternative is deciding, in the middle of an incident, which of two identical tokens is the honest one.
What it costs an honest player
Almost nothing, which is the point. A normal session rotates quietly and the player never learns that any of this exists. The cost lands on the rare bad day, and it lands as a sign-in rather than as a stolen account.
The cost to me is an archive that grows, and a test suite that has to prove the blunt rule really is blunt, including the case where an honest client retries a request it had already spent.
What I would keep
Decide what a session is once, for every door. Keep the corpse of a rotated token rather than the token itself. And when the evidence says two parties hold one secret, end the session rather than trying to guess which party deserves it.