Skip to content
Back to Homephamkhanhminhman.com / blog
Auth / Concurrency• 26 Sept 2026• 10 min read

Refresh token rotation caught the thief. It also caught my second tab.

My notes on JWT have one entry about refresh tokens. Translated, this is all of it:

  1. Every time a new access token is issued, issue a new refresh token too, and keep the old one.
  2. Once an old refresh token is used again, no need to tell the user from the hacker: revoke every active refresh token.
  3. The user logs in again and gets a new one.

This is refresh token rotation with reuse detection, and it is not folklore. RFC 9700, the current OAuth security best practice, requires either this or sender-constrained tokens for public clients such as single-page apps, and describes the same trade: when an invalidated refresh token comes back, the server “cannot determine which party submitted” it, so it revokes the active one and makes the legitimate client log in again.

The note stops where the RFC stops. It says what to do when an old token comes back. It does not ask how often one comes back when nobody has stolen anything. So I built the three lines and counted.

The server runs on Node.js 22 with PostgreSQL 18.6 in Docker. Refresh tokens are 32 random bytes, stored as SHA-256 hashes with a pointer to the token they replaced. Access tokens are HS256 JWTs that live 15 minutes; the page-load tests start with one that has already expired, and the theft test uses a 5-second one. The browser tests keep the refresh token in an HttpOnly cookie and ran in Chromium 153. The results held when I repeated them in a second session; each table says how many times per run.

1. The case it was built for: it works, with two gaps

First the thief. Someone copies the refresh token RT0 and uses it before the real user does:

attacker  POST /auth/refresh   RT0   200  gets RT1 and an access token
user      POST /auth/refresh   RT0   401  reuse: every refresh token revoked
attacker  POST /auth/refresh   RT1   401  revoked
attacker  GET  /api/me         AT    200  ...and 200 until it expires

In the other order, user first, the attacker's RT0 is the reused one and gets a 401 at once, and the user's new token is revoked with it. Either way the chain ends and both parties have to log in again. That is the design working.

The note leaves out two things. Revoking refresh tokens does not recall an access token that has already been issued: a JWT is checked by its signature and expiry, so the attacker's kept working until it expired, within five seconds with a 5-second token, up to 15 minutes with a real one. And detection needs the real user to come back. With the user away, the attacker refreshed 96 times in a row, as many refreshes as a day of 15-minute tokens needs, without a single error; the reuse was noticed only when the user's browser presented RT0 again. Rotation limits a theft to the user's absence; when the user is away for a week, a maximum lifetime for the whole chain is what limits it further.

2. Nobody stole anything: one page, two tabs

Now the case the note does not mention. The access token has expired and a page loads that makes three API calls at once. Each gets a 401 and goes through the usual interceptor: refresh, then retry. All three refreshes carry RT0, because none has come back yet to replace it.

Clientpage loadslogged out
2 calls, each refreshes on 401200200
3 calls, each refreshes on 401200200
3 calls, one shared refresh2000

Every time. The first refresh rotates RT0 to RT1; the second arrives with RT0, and to the server that is exactly what theft looks like. It revokes everything, including the RT1 it has just issued. One call on the page got its data; the others got a 401 from the refresh endpoint, and the app sends the user to the login page.

Two browser tabs do the same with a cookie. I opened two tabs of the same site, with the refresh token in an HttpOnly cookie, and had both refresh at once:

Two tabs, Chromium 153trialslogged out
plain fetch3030
refresh inside navigator.locks300

Two same-origin iframes gave the same 30 and 0. How close is “at once”? I delayed every refresh response by 100 ms to stand in for the network, and started the second refresh 0 to 150 ms after the first. Every gap up to 90 ms logged the user out, 20 times out of 20; from 110 ms on, none did. The window is one round trip, however long the network makes it. Refreshes that start within one round trip of each other are ordinary: the parallel requests of one page after its token expired, or, I would expect, every open tab waking together when a laptop comes out of sleep.

3. A response that never arrived

The third honest reuse needs only one request. The client sends RT0, the server rotates it and commits, and the response is lost: a proxy times out, a phone changes networks. The client still holds RT0, so it retries with RT0. I made the server answer after 300 ms and the client give up after 200 ms: 50 retries out of 50 were treated as theft and ended the session. Okta's documentation gives this exact case, users on poor connections whose new tokens never reach the app, as the reason its grace period exists.

4. The race in the check itself

Written the obvious way, the server follows the note in order: read the token, check it has not been used, issue the next one, mark the old one used. Three statements, no lock. With two refreshes at once, that version almost never logged anybody out.

Server2 refreshes at oncelogged outtwo live tokens
read, check, then write2001199
one atomic UPDATE2002000

Both requests read “unused” before either wrote “used”, so both got a new token. The single-use token was used twice, the family now had two valid refresh tokens, and the one the page did not keep stays valid, held by no one, until it expires. With the 100 ms response delay and both runs added together, gaps of 0 to 2 ms produced two tokens 114 times in 120, a 3 ms gap about half the time, and from 5 ms on the result was almost always a logout, every time from 10 ms. The race covered only a few milliseconds, the time between the SELECT and the UPDATE, but on my machine that is where the parallel requests of one page landed.

The fix is to check and claim the token in one statement:

UPDATE refresh_tokens
SET    used_at = now()
WHERE  token_hash = $1 AND used_at IS NULL AND revoked_at IS NULL
RETURNING id, user_id, family_id;
-- one row: this request owns the rotation
-- no row:  unknown, revoked or already used; find out which

At PostgreSQL's default Read Committed level, a second UPDATE on the same row waits for the first to commit, re-checks used_at IS NULL, and matches nothing. That makes the token genuinely single-use, and it turns the collisions from section 2 into logouts 200 times out of 200. The buggy version had been hiding the problem. A correct implementation of the note has to decide what to do when its own user reuses a token.

5. A grace window, built two ways

The usual answer is a short window during which the previous token is still accepted. Okta calls it a grace period, 30 seconds by default and configurable from 0 to 60; Auth0 calls it a rotation overlap period. If you build your own, there are two obvious ways to answer inside the window: return the successor you already issued, or issue another one. I built both, with a one-second window:

Inside the window3-call page, logged outlost response, logged outextra live tokensthief replays in time
nothing (no window)200 / 20050 / 50nonecaught
return the successor0 / 2000 / 50nonecaught at its next refresh
issue another token0 / 2000 / 50two per 3-call pagenot caught in 8 rounds

Both remove the logouts. Only one keeps the detection. Returning the successor means a thief who replays RT0 in time receives the same RT1 the user holds, and a working access token; once the window has closed, whichever of them uses RT1 second is caught. In my runs that was the attacker's next refresh, and as in section 1 the user had to log in again as well. Issuing another token forks the family: the user carries on along one chain, the thief along another, and neither ever presents a used token again. After eight more rounds of refreshes on both sides, the family still had two active tokens and no reuse had been detected. The same fork happened on every page load with parallel calls: each three-call page left three active refresh tokens, two of them held by no one.

That fork is what happens when nothing ties the extra token to the rest of the family. Auth0's documentation says a new token is issued during its overlap period and that only the previous token can be reused, but not how that new token is linked to the others; I did not test Auth0. Returning the successor means keeping it for the length of the window, in Redis or wherever you keep short-lived state. I wrote it before committing the rotation; writing it just after the commit worked as well in 600 page loads, with up to 3 ms of simulated store latency, because the waiting request has two more queries to run before it looks.

6. Fix the client as well

The window does not make parallel refreshes free: each still replays or rotates, and each is a trip to the database. The client should refresh once. Within a page that is one shared promise; across tabs it is the Web Locks API, available in every major browser since March 2022:

let inflight = null;

// One refresh per page, and one per origin at a time across tabs.
function refreshTokens() {
  inflight ??= navigator.locks
    .request("refresh-token", async () => {
      const res = await fetch("/auth/refresh", { method: "POST" });
      if (!res.ok) throw new Error("session ended");
      return (await res.json()).access;
    })
    .finally(() => { inflight = null; });
  return inflight;
}

Every caller that got a 401 awaits the same promise and retries with the access token it returns. Run as written in Chromium, this function took a three-call page to 0 logouts in 100 and two tabs to 0 in 30. The second tab still refreshes, but only after the first has finished and the browser has stored the new cookie, so it sends RT1 instead of RT0. The client fix cannot help with a lost response, though; only the server's window can. You need both. Workers on a server have the same problem and a different fix, a lock per shop, which I wrote about in the Shopee token post.

7. Revoke what, exactly?

The note says to revoke every active refresh token. Read as “every token the user has”, one collision in one browser logs the user out everywhere. I logged one user in on three devices and made two tabs collide on the first:

On reuse, revokedevice 1devices 2 and 3
every token of the userlogged outlogged out
the family (one login's chain)logged outstill signed in

RFC 9700 describes the second: the server revokes the active refresh token of the grant the reused one belongs to. A family id on each token, set at login and copied on every rotation, is enough to find it. Narrowing the scope does not stop the logout on device 1; that takes sections 5 and 6.

What the notes say now

  • Rotate on every refresh, and claim the old token with one atomic UPDATE. Read-then-write let a single-use token be used twice 199 times in 200.
  • An old token coming back is usually your own client. Parallel calls, a second tab, a lost response: of the four ways an old token came back in this post, one was a thief.
  • Give the previous token a short window, and inside it return the token you already issued. Issuing another one, with nothing tying it to the family, forked it and hid the thief for eight rounds.
  • Refresh once per page, and once per origin at a time. A shared promise and navigator.locks.
  • On reuse, revoke the family, not the user.
  • Revocation does not recall access tokens, and detection waits for the real user. Keep access tokens short and cap the lifetime of a family.

The note was right about the thief. What it never asked is who else sends an old refresh token. In this post it was a second tab, a page's parallel requests and a lost response, and every one of them was the real user.

Auth / ConcurrencyBack to Home