How Six Parallel Dashboard Calls Triggered Our Refresh Token Reuse Detection and Logged Out 740 Users
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.
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:
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.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.
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.
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
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.