Writing · Field note 02

Rotating refresh tokens, and the day one shows up twice

Most people rotate refresh tokens for basic hygiene. It turned out to be more useful than that. Once a token dies the moment it's used, a token that shows up twice is telling you something, and you can act on it.

11 min read JWT access · rotating refresh · Redis blacklist

Nura uses two tokens to keep you logged in. There's a short-lived access token, good for fifteen minutes, that the server can check on its own without a database lookup. And there's a refresh token, good for ninety days, that the app trades in for a new access token when the old one runs out.

The interesting decisions all live in how those two tokens interact, especially when one of them gets stolen. The diagram below walks through a normal rotation and then a reuse. Here's the reasoning behind each piece.

Fig. 02 · Token rotation & replay detection JWT · Redis blacklist
auth server RT · 1 valid RT · 2 valid RT · 3 valid rotate rotate access · 15m your device holds RT · 3 another device replays RT · 1
Idle. Press play to watch a session rotate, then get replayed.

Every refresh hands back a new token and retires the old one. If an already-rotated token shows up again, that's the fingerprint of a stolen session, and rejecting that one request isn't enough. So I revoke every session for that user at once. For them, it means a leaked token can't quietly hold a door open somewhere else.

Why not just one long-lived token

The simplest version is a single token that lasts a long time. One credential, no rotation, no refresh dance. The problem is what it costs you when it leaks. If that one token is valid for ninety days and someone copies it, they've got ninety days of access, and there isn't much you can do about it that doesn't also log out every honest user.

Splitting it in two gives me a short window where it matters most. The access token is the one flying around on every request, so it's the one most likely to be exposed somewhere. Keeping it alive for only fifteen minutes means a stolen access token is close to worthless on its own. It expires before anyone can do much with it. The refresh token, the one that could do real damage, moves around far less. It only comes out to fetch a new access token, and I can wrap that one moment in more checks.

Why the refresh token is stored hashed

I never store the refresh token as-is. I hash it and store the hash, the same way you'd handle a password. The reason is simple. My database is not the only place I have to worry about. Backups get made, logs capture more than they should, and databases occasionally end up somewhere they were never meant to be. If any of that happens and the tokens are sitting there in plain text, every one of them is immediately usable by whoever is holding the copy.

If they're hashed, a leaked database is just a pile of hashes that can't be replayed against my server. When a real token comes in, I hash it the same way and compare. Same idea as passwords, and the same reasoning behind it: assume the store will leak one day, and make the leak worthless when it does.

What reuse means, and why I kill everything

Every time the refresh token is used, it's retired and replaced with a new one. That rotation is what makes theft detectable. A refresh token is meant to be used exactly once. So if a token that was already used shows up again, something is off. Either the same token exists in two places, or an old one is being replayed. In practice that usually means it got copied.

The tempting response is something gentle. Reject that one request, maybe flag the account, and carry on. I went the other way and revoke every session for that user at once. The reason is that at the moment of reuse, I genuinely can't tell the thief from the victim. Both are holding what looks like a valid token. If I only kill the request that looked suspicious, I might have just logged out the real user and left the attacker's session running. Killing all of them is the only response guaranteed to include the bad one. It's blunt, and it will occasionally sign out a real person for a reason they don't understand. I decided a surprise re-login is a fair price for making sure a stolen session can't quietly outlive the theft.

The mobile race I didn't see coming

There's an access token blacklist in Redis so I can kill a token immediately instead of waiting out its fifteen minutes. That part is straightforward. The part that bit me was on the phone.

Picture the app opening. Several requests fire at once, the home screen, the user's history, their settings. The access token has just expired. So all of those requests come back 401 at roughly the same instant. Each one independently decides its token expired and it should refresh, and each one fires off a refresh carrying the same refresh token. Now I've got three or four refresh calls racing, all holding a token that's only valid once. The first one wins and rotates it. The rest land a moment later carrying what is now an already-used token.

And I just spent the last section explaining what the server does when it sees a reused refresh token. It assumes theft and logs the user out of everything. So on a totally normal app launch, the client would report itself as an attacker and nuke its own session.

The fix is to let only one refresh happen at a time. On the client, the first 401 kicks off the refresh, and every other request that hits a 401 waits in line behind it instead of starting its own. When that single refresh finishes, the waiting requests pick up the new token and retry. One refresh, one rotation, no false alarm. It's a small piece of code, but without it the security measure and the client end up fighting each other, and the client loses.

What I'd reconsider

The revoke-everything call is the one I go back and forth on. For an app people open every day, forcing a full re-login is a real annoyance, and most reuse events are probably races and edge cases rather than actual attackers. A softer system might revoke by device, or step up to a re-authentication instead of a hard logout. I kept the blunt version because the data here is genuinely private, and the cost of being wrong in the attacker's favor felt higher than the cost of an occasional annoyed user. But that's a product call as much as a security one, and I'd happily revisit it once I have real numbers on how often reuse actually fires.