How Six Parallel Dashboard Calls Triggered Our Refresh Token Reuse Detection and Logged Out 740 Users
← Back
September 21, 2026Security9 min read

How Six Parallel Dashboard Calls Triggered Our Refresh Token Reuse Detection and Logged Out 740 Users

Published September 21, 20269 min read

22:07 UTC. Six days after the new dashboard overview page went out to everyone, support's ticket queue jumps from its usual trickle to fourteen tickets in six minutes, all some version of the same sentence: "I was just looking at my dashboard and it logged me out, and now my password won't work either."


the setup

The auth model was standard OAuth2-style: a short-lived access token (15 minutes) and a long-lived refresh token (30 days), with rotation on every refresh. Each time the frontend traded a refresh token for a new access token, the server issued a brand-new refresh token too and marked the old one consumed. If a consumed refresh token ever showed up again, the server treated that as evidence the token had been stolen and copied, not just an in-flight request behaving oddly, and revoked the entire token family, every access and refresh token descended from it. That's a standard mitigation against stolen-token replay, documented in the OAuth2 security BCP, and it had been running without incident for over a year.

The thing that shipped six days earlier was smaller than the auth system: a new dashboard overview page that replaced four separate tabs with one screen showing everything at once. On mount, it fired six requests in parallel with Promise.all instead of the single request the old tab-based page made.

Overview.tsx, the change that shipped six days earlier
useEffect(() => {
  Promise.all([
    api.get('/dashboard/summary'),
    api.get('/dashboard/notifications'),
    api.get('/dashboard/billing'),
    api.get('/dashboard/projects'),
    api.get('/dashboard/team'),
    api.get('/dashboard/activity'),
  ]).then(setAll);
}, []);

Nobody flagged it in review as an auth risk. It's six GET requests against six existing, already-shipped endpoints. The access token had a 15-minute lifetime specifically so that a stolen one wouldn't be useful for long; nobody had reasoned through what happens when six requests all discover that token is expired within the same tick of the event loop.


the scramble

First theory: the auth service had shipped a config change shortening session TTLs, and users were getting logged out faster than they expected. The on-call engineer pulled the auth service's deploy history. Nothing had touched the token TTL config in three weeks. Ruled out in under five minutes.

Second theory: a JWKS key rotation had gone out of sync between the auth service's pods, so some requests were being verified against a signing key that had already rotated out. That would produce invalid_signature errors though, and the auth service's logs showed something more specific and, at first glance, more confusing: invalid_grant: refresh_token_reuse_detected, over and over, for accounts that had no reason to be sharing credentials or getting phished at this volume.

Third observation: every affected account had loaded the new overview page in the minutes before getting logged out. That correlation was noted around 22:19 UTC and almost dismissed, because the overview page was six days old and had been quietly used by thousands of sessions without incident. The obvious conclusion was that it couldn't be the trigger. It was.


the hunt

The auth service logs, filtered to a single affected account, told the real story once someone looked at the timestamps to the millisecond instead of the second:

auth-service logs, one account, 22:11:03 UTC
22:11:03.114  POST /auth/refresh  token_id=a91f  -> issued new_token_id=b220 (rotated)
22:11:03.121  POST /auth/refresh  token_id=a91f  -> REUSE DETECTED, already consumed
22:11:03.121  revoking family=fam_7e2c (2 access, 4 refresh tokens)
22:11:03.129  POST /auth/refresh  token_id=a91f  -> family revoked, request denied
22:11:03.134  POST /auth/refresh  token_id=a91f  -> family revoked, request denied

Four refresh calls, 20 milliseconds apart, all presenting the exact same refresh token a91f. The first one won the race, rotated the token, and got a valid new session. The other three arrived after the server had already marked a91f consumed, tripped the reuse check, and the whole family got revoked, including the one refresh call that had just succeeded. From the server's side this is indistinguishable from an attacker replaying a stolen token. From the client's side, it's four requests that all hit 401 within the same render cycle and each independently decided it was responsible for refreshing.

The frontend's response interceptor was written per-request, not shared across a batch:

api-client.ts, before the fix
api.interceptors.response.use(
  (res) => res,
  async (error) => {
    if (error.response?.status === 401) {
      const { data } = await axios.post('/auth/refresh', {
        refreshToken: getRefreshToken(),
      });
      setAccessToken(data.accessToken);
      setRefreshToken(data.refreshToken);
      return api.request(error.config);
    }
    return Promise.reject(error);
  }
);

Every one of the six overview-page requests carries its own copy of this interceptor logic. When the access token is still valid, that's harmless duplication. The instant it expires mid-batch, every request that hits 401 reads the same refresh token from storage and fires its own refresh call before any of them has had a chance to see a new one written back.


the find

Root cause: the frontend had no single-flight guarantee around token refresh. Refresh token rotation and reuse detection were both correct in isolation. They assumed, reasonably, that a given refresh token would only ever be presented once at a time. Batching six requests on one page load broke that assumption the moment the access token's 15-minute window ran out mid-batch, which single-tab load testing never exercised because it never fired more than one request at a time against an expiring token.


the fix

The fix was to make the refresh call itself single-flight: the first 401 starts a refresh and stores the in-flight promise; every 401 that arrives while that promise is still pending awaits the same promise instead of starting a new request.

api-client.ts, after the fix
let refreshPromise: Promise<string> | null = null;

async function refreshAccessToken(): Promise<string> {
  if (!refreshPromise) {
    refreshPromise = axios
      .post('/auth/refresh', { refreshToken: getRefreshToken() })
      .then(({ data }) => {
        setAccessToken(data.accessToken);
        setRefreshToken(data.refreshToken);
        return data.accessToken;
      })
      .finally(() => {
        refreshPromise = null;
      });
  }
  return refreshPromise;
}

api.interceptors.response.use(
  (res) => res,
  async (error) => {
    if (error.response?.status === 401) {
      const token = await refreshAccessToken();
      error.config.headers.Authorization = `Bearer ${token}`;
      return api.request(error.config);
    }
    return Promise.reject(error);
  }
);

With that in place, six requests hitting 401 in the same batch produce exactly one call to /auth/refresh; the other five wait on the shared promise and retry with whatever access token comes back. The auth service's reuse detection never sees a second presentation of the same refresh token, because there never is one.

As a second line of defense, the auth service also got a short grace window: a refresh token presented within two seconds of being consumed returns the already-issued replacement instead of triggering reuse detection, which absorbs the rare cross-tab case where two separate browser tabs race each other instead of two requests from the same page.

auth-service, refresh handler
const consumed = await db.refreshTokens.findConsumed(tokenId);
if (consumed) {
  const withinGrace = Date.now() - consumed.consumedAt < GRACE_WINDOW_MS;
  if (withinGrace) {
    return res.json(consumed.replacementTokens);
  }
  await revokeFamily(consumed.familyId);
  return res.status(401).json({ error: 'refresh_token_reuse_detected' });
}

the aftermath

8 days From the overview page shipping to the interceptor fix going out
740 Forced logouts recorded during the 46-minute spike on 22:07 UTC
1,140 Total reuse-detection revocations across the 8-day window
0 Reuse-detection revocations from legitimate sessions since the fix

A dashboard alert now watches the auth service's refresh_token_reuse_detected rate directly, separate from the generic error-rate panel, because this failure mode never showed up as elevated 500s. Every response involved was a correct 401 or 401-turned-200, which is why it took a support ticket spike instead of an automated alert to surface it. The staging load test for the auth flow was extended to open every dashboard route that fires more than one request on mount and force the access token to expire mid-batch, instead of only testing refresh under a single sequential request.

  • Refresh token rotation and reuse detection are correct in isolation and can still misfire against a perfectly legitimate client, if that client is capable of presenting the same token twice. The security feature wasn't wrong; the assumption that no legitimate client would ever race itself was.
  • A page that goes from one API call to six looks like a performance change in code review, not an auth change. The actual auth surface area of a page is however many requests can hit a 401 at once, not how the auth code itself was written.
  • A failure mode built entirely out of individually-correct 401 and 200 responses won't trip a generic error-rate alert. It needs its own signal, tied to the specific mechanism, not the symptom.
  • Single-flight deduplication belongs around any client-side call that mutates shared, one-time state, refresh tokens included. If a value can only be spent once, only one in-flight request should be allowed to try to spend it.

The token family revocation had been doing exactly what it was built for the entire time: treating a repeated refresh token as theft. It just turned out the thief, this once, was four copies of the same browser tab, a beat too eager to prove it was the one keeping the user logged in.

Share this
← All Posts9 min read