[{"data":1,"prerenderedAt":4},["ShallowReactive",2],{"post-content-refresh-token-race-condition-multi-tab":3},"\u003Cp>Last month a user emailed me a video of the bug. They had five tabs of our app open, working through a checklist, and every thirty minutes or so one of the tabs silently kicked them out. Not a session-expired screen — the app just started 401ing, then dumped them at the login page. Close the tab, log back in, and the other four tabs were fine. For a while.\u003C\u002Fp>\n\n\u003Cp>I blamed the user. \"Who keeps five tabs of a web app open?\" Then I did it myself, and within an afternoon I watched my own access token die in front of me. It wasn't the user. It was a \u003Cstrong>refresh token race condition\u003C\u002Fstrong>, and it's hiding in more production apps than people want to admit.\u003C\u002Fp>\n\n\u003Cp>The short version: your access token expires, two tabs notice at the same time, both try to refresh with the same refresh token, and your backend's token rotation only lets one of them win. The loser gets logged out. This post is about why that happens, the frontend fixes that reduce it, and the backend change that actually kills it.\u003C\u002Fp>\n\n\u003Ch2>The exact sequence that logs you out\u003C\u002Fh2>\n\n\u003Cp>Here's the timeline that plays out in every affected app, second by second:\u003C\u002Fp>\n\n\u003Col>\n\u003Cli>Your app gets an access token (15 minutes, say) and a refresh token (7 days) when you log in.\u003C\u002Fli>\n\u003Cli>You open the app in tab A at 9:00 and tab B at 9:10. Both tabs hold the same tokens.\u003C\u002Fli>\n\u003Cli>At 9:15, tab A's access token expires. Tab A fires a refresh request.\u003C\u002Fli>\n\u003Cli>At 9:15 and 0.2 seconds, tab B's access token expires too. Tab B fires its own refresh request — with the same refresh token.\u003C\u002Fli>\n\u003Cli>The server processes tab A's request, rotates the refresh token (invalidating the old one), and returns a new pair.\u003C\u002Fli>\n\u003Cli>Tab B's request arrives with the now-invalidated refresh token. The server says \"reuse detected, this is a compromise.\" It kills the whole session family.\u003C\u002Fli>\n\u003Cli>Both tabs are now logged out. The user watches it happen and files a bug report with a screen recording.\u003C\u002Fli>\n\u003C\u002Fol>\n\n\u003Cp>The window between step 3 and step 6 is tiny — usually under a second. That's why this bug is so nasty: it's timing-dependent, it doesn't reproduce on every refresh, and QA never catches it because QA doesn't leave five tabs open for 45 minutes.\u003C\u002Fp>\n\n\u003Ch2>Rotation is the root cause, not the symptom\u003C\u002Fh2>\n\n\u003Cp>Let's be clear about who's at fault here. Your frontend coordination code is a band-aid. The design decision that makes this bug possible is \u003Cstrong>refresh token rotation\u003C\u002Fstrong> — issuing a brand new refresh token on every refresh and immediately invalidating the old one.\u003C\u002Fp>\n\n\u003Cp>Rotation exists for a good reason. If a refresh token leaks, rotation limits how long the thief can use it, and it gives you a signal (the reuse of an old token) that something is wrong. Auth0, Okta, and most auth providers rotate by default. So you can't just delete rotation and call it a day — but you do need to decide what \"reuse\" means in a world where your legitimate user runs five tabs.\u003C\u002Fp>\n\n\u003Cp>There are two philosophies here:\u003C\u002Fp>\n\n\u003Cul>\n\u003Cli>\u003Cstrong>Rotating with a reuse window (what Auth0 does).\u003C\u002Fstrong> If a refresh token is used again within a short grace period — say 30 seconds — the server assumes it's the same user's other tab, not a thief. It hands out a fresh access token without rotating the refresh token or killing the session.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Non-rotating sliding sessions.\u003C\u002Fstrong> The refresh token stays the same; every refresh extends its expiry. Multi-tab just works, because there's nothing to race over. The cost is that a leaked refresh token stays valid until it expires.\u003C\u002Fli>\n\u003C\u002Ful>\n\n\u003Cp>I've shipped both. For an internal tool, sliding sessions are fine. For a consumer product, rotate but give yourself a reuse window. Either way, the backend should treat \"same refresh token used twice within N seconds\" as normal, not as an attack.\u003C\u002Fp>\n\n\u003Ch2>Frontend fix #1: single-flight refresh in one tab\u003C\u002Fh2>\n\n\u003Cp>Even with a good backend, you don't want your client firing five refresh requests in a row. Most of the damage happens before the server even sees the request — your interceptor queues up a pile of 401s and each one triggers its own refresh.\u003C\u002Fp>\n\n\u003Cp>The simplest fix is to make the refresh request single-flight: one in-flight promise, shared by every caller. Here's the whole pattern:\u003C\u002Fp>\n\n\u003Cpre>\u003Ccode class=\"language-javascript\">let refreshPromise = null\n\nasync function getValidAccessToken() {\n  if (!refreshPromise) {\n    refreshPromise = fetch('\u002Fauth\u002Frefresh', {\n      method: 'POST',\n      credentials: 'include'\n    })\n      .then((res) => {\n        if (!res.ok) throw new Error('refresh failed')\n        return res.json()\n      })\n      .then((data) => data.access_token)\n      .finally(() => { refreshPromise = null })\n  }\n  return refreshPromise\n}\u003C\u002Fcode>\u003C\u002Fpre>\n\n\u003Cp>Every 401 handler calls \u003Ccode>getValidAccessToken()\u003C\u002Fcode>. The first call starts the fetch; every subsequent call gets the same promise. If you have twelve requests fail at once, you fire exactly one refresh.\u003C\u002Fp>\n\n\u003Cp>This handles the within-tab race completely. It does nothing for the across-tabs race, because each tab has its own \u003Ccode>refreshPromise\u003C\u002Fcode>. For that you need coordination.\u003C\u002Fp>\n\n\u003Ch2>Frontend fix #2: BroadcastChannel coordination\u003C\u002Fh2>\n\n\u003Cp>To stop two tabs from refreshing simultaneously, they need to talk to each other. The \u003Ccode>BroadcastChannel\u003C\u002Fcode> API is built for exactly this — same-origin messaging between tabs, no server involved.\u003C\u002Fp>\n\n\u003Cp>The idea: the first tab to need a refresh becomes the refresher. The others just wait for the result to be broadcast.\u003C\u002Fp>\n\n\u003Cpre>\u003Ccode class=\"language-javascript\">const channel = new BroadcastChannel('auth-refresh')\nlet refreshing = false\n\nasync function refreshAcrossTabs() {\n  if (refreshing) {\n    \u002F\u002F Another refresh is in flight in this tab — wait for the broadcast\n    return new Promise((resolve) => {\n      const onMessage = (e) => {\n        if (e.data?.type === 'token-refreshed') {\n          channel.removeEventListener('message', onMessage)\n          resolve(e.data.token)\n        }\n      }\n      channel.addEventListener('message', onMessage)\n    })\n  }\n\n  refreshing = true\n  try {\n    const token = await doRefresh()\n    channel.postMessage({ type: 'token-refreshed', token })\n    return token\n  } finally {\n    refreshing = false\n  }\n}\u003C\u002Fcode>\u003C\u002Fpre>\n\n\u003Cp>I'll be honest about the limits of this one: it dramatically cuts the collision rate, but it doesn't mathematically eliminate it. Two tabs can both pass the \u003Ccode>refreshing\u003C\u002Fcode> check before either receives the other's broadcast, because the message round-trip is asynchronous. That's exactly why the backend reuse window from earlier matters — the frontend reduces the race, the backend absorbs what's left.\u003C\u002Fp>\n\n\u003Cp>One more frontend trick worth knowing: listen for the \u003Ccode>storage\u003C\u002Fcode> event. If tab A refreshes and gets a new token, it can write a timestamp to \u003Ccode>localStorage\u003C\u002Fcode>, and every other tab gets notified and can reload its tokens. It's uglier than BroadcastChannel but it works everywhere, including browsers from 2019.\u003C\u002Fp>\n\n\u003Ch2>The backend fix that ends the fight\u003C\u002Fh2>\n\n\u003Cp>At the end of the day, the only person who can fully resolve a refresh token race is the server, because it's the only one that sees both requests. Here's the reuse-window check, in the shape I've actually run in production:\u003C\u002Fp>\n\n\u003Cpre>\u003Ccode class=\"language-javascript\">\u002F\u002F On POST \u002Fauth\u002Frefresh, after validating the refresh token:\nconst session = await redis.get(`refresh:${tokenId}`)\n\nif (session && session.rotatedAt) {\n  const reusedInsideWindow = Date.now() - session.rotatedAt &lt; 30_000\n  if (reusedInsideWindow) {\n    \u002F\u002F Same user, second tab. Don't rotate again, don't kill anything.\n    \u002F\u002F Hand out a fresh access token and extend the session.\n    return issueAccessToken(session.userId, session.familyId)\n  }\n  \u002F\u002F Outside the window: real reuse. Rotate the family and alert.\n  await revokeSessionFamily(session.familyId)\n}\n\n\u002F\u002F Normal path: rotate the refresh token, record rotatedAt.\nconst newToken = rotateToken(session)\nawait redis.set(`refresh:${newToken.id}`, { ...session, rotatedAt: Date.now() })\u003C\u002Fcode>\u003C\u002Fpre>\n\n\u003Cp>That's it. Thirty seconds of grace turns \"two tabs = logout\" into \"two tabs = slightly more work for the server.\" You can tune the window — I use 30 seconds, and I've seen teams run 60. The window should be long enough to cover a slow refresh round-trip on a bad connection, which is exactly the case that makes the race worse.\u003C\u002Fp>\n\n\u003Ch2>What I stopped doing\u003C\u002Fh2>\n\n\u003Cp>A few habits I had to unlearn while debugging this:\u003C\u002Fp>\n\n\u003Cul>\n\u003Cli>\u003Cstrong>Refreshing on every 401 blindly.\u003C\u002Fstrong> Now I dedupe first, then refresh, then retry the original request exactly once. If it 401s again, the session is genuinely dead.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Storing the refresh token in localStorage.\u003C\u002Fstrong> It's readable by any script on your origin, which makes the \"leaked token\" scenario you're defending against more likely than the race you're fixing. \u003Ccode>httpOnly\u003C\u002Fcode> cookies or a memory-held token are better.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Testing auth with one tab.\u003C\u002Fstrong> I keep a smoke-test checklist that opens three tabs, waits for an expiry cycle, and confirms all three survive. It's caught two regressions since.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Treating \"reuse detected\" as an attack automatically.\u003C\u002Fstrong> With rotation and a reuse window, the only thing that kills a session is reuse outside the window. That's a much higher-signal alert than \"someone refreshed twice.\"\u003C\u002Fli>\n\u003C\u002Ful>\n\n\u003Ch2>Check your codebase right now\u003C\u002Fh2>\n\n\u003Cp>You can tell if you have this bug in about five minutes:\u003C\u002Fp>\n\n\u003Col>\n\u003Cli>Open your app in two tabs.\u003C\u002Fli>\n\u003Cli>Set the access token lifetime to 60 seconds in your auth config (or in your dev server).\u003C\u002Fli>\n\u003Cli>Wait for expiry, then use the app in both tabs.\u003C\u002Fli>\n\u003Cli>If either tab logs you out or the network tab shows two simultaneous refresh calls with one failing — you have the race.\u003C\u002Fli>\n\u003C\u002Fol>\n\n\u003Cp>If you do, fix the backend first. The reuse window is a fifteen-minute change and it protects every client you'll ever ship, including the mobile app you haven't written yet. Then add single-flight, and only reach for BroadcastChannel if you actually see tabs colliding in your logs.\u003C\u002Fp>\n\n\u003Cp>I keep the single-flight pattern and the BroadcastChannel snippet in \u003Ca href=\"https:\u002F\u002Fdevspera.com\u002Fsnippetark\u002F\">Snippet Ark\u003C\u002Fa> now, right next to the interceptor code, because every project I've touched in the last year has needed one of them. Auth races are one of those bugs that looks exotic until it's yours — and then it's every other week.\u003C\u002Fp>\n\n\u003Cp>How many tabs does your app survive? If you've got a logout-race story of your own, I'd genuinely like to hear it — especially the part where support blamed the user first.\u003C\u002Fp>\n",1787133717902]