Concurrent refresh wipes the session
Part 11 of an ongoing series on Moty, a fitness data platform I build and run solo in production: one multi-tenant API, two Next.js frontends. All numbers come from production measurements and git history.
Refresh token rotation and reuse detection. Both are textbook-correct security designs. Put them together, and opening a single screen can log a user out.
I was about to add automatic token refresh to the admin console. I had written the server, and I was writing the console.
The console has places where a screen fires API calls in parallel on entry. The announcements list, for example, fetches three status counts at once. If the access token has expired at that moment, all three requests get a 401, and each one independently tries to refresh with the same refresh token.
Halfway through the client code, my hands stopped.
Wait — after rotation, is there a grace period for the old refresh token?
I opened the server code. There was no grace period. And there was something worse than not having one.
Why there are two tokens
Login issues two tokens: the access token you attach to every request, and the refresh token you use to get a new one when it expires.
Token | Lifetime | Role |
|---|---|---|
access | 10 min | the pass shown on every API request |
refresh | 1 hour | the voucher traded for a new pass |
Admin console client. The apps use a 1-hour access token.
The reason access tokens are short is obvious: if one is stolen, it dies in ten minutes. But you can't make people log in every ten minutes, so the refresh token quietly renews it.
Why the voucher is single-use
Use a refresh token once and it's destroyed on the spot; you get a new one. That's rotation. The same voucher can never be spent twice, so a stolen token's life is capped at a single use.
Then one step further. What if an already-spent voucher shows up again? A legitimate user has no reason to do that. So someone must have copied it. This is what RFC 6819 calls reuse detection, and the prescribed response is to kill every session that user has. The thief gets nothing, and the owner gets logged out too.
// Deleted from Redis → true; already gone → false. Atomic verdict.
Boolean deleted = redisTemplate.delete(buildKey(userId, jti));
if (!deleted) {
// A spent token came back → treat as theft
revokeAll(userId);
throw new BusinessException(AuthErrorCode.INVALID_TOKEN);
}revokeAll deletes every refresh token for that user and invalidates the access tokens too. At least, that's how it reads. (That sentence gets overturned by measurement later.)
One screen is three requests
The announcements screen fetches three status counts. Calling them in sequence is slow, so it fires them together, which is a reasonable optimization. The problem is that the three requests know nothing about each other.
An admin leaves the console open, steps away for more than ten minutes, comes back, and opens this screen. The access token is already dead.
Three requests hold the same voucher
t+0 A B C 401 · token expired
All three get the expiry response at once.
Each starts its own refresh.
t+n A rotation succeeds ✓
The Redis key is deleted. A gets a new refresh token.
t+n+ε B reuse detected · revoke everything ✗
The key is already gone. Treated as theft; revokeAll runs.
t+n+ε' C fails ✗
Nothing left.
Result — the admin is fully logged out. Back to the login screen.I later measured this for real. The gap between the winner's response and the first reuse-detection response was 0.4–2.2 ms, and all three finished within 10.9–13.9 ms (five runs). That ε is in milliseconds, and this number turns out to be decisive later.
The winner dies
The result you'd expect is "one succeeds, the rest fail." That's what I expected too, and it didn't worry me much; retrying the failed ones would cover it.
What actually happens is that the failed requests invalidate the successful one. A refreshed correctly, but a few milliseconds later, B's detection logic sweeps away A's brand-new refresh token too. There is no winner.
That was the picture I built by reading the code. I'd learn later that this picture was only half right.
Why I didn't add a grace period on the server
The easiest mitigation is "keep accepting the old token for a few seconds after rotation." Real implementations do this. But those few seconds are exactly the window reuse detection exists to close.
The only difference between an attacker holding a stolen token and a legitimate client is timing. Open a grace window, and inside it the two are indistinguishable. It trades away the purpose of a security mechanism for client convenience.
The server stays strict. The fix is to make the client never refresh more than once at a time.
The fix: single-flight
Merge the refresh calls into one. Only the first actually goes to the server; everything arriving in the meantime waits for that result and reuses it.
[before] [single-flight]
A ─→ refresh ─┐ A ─→ refresh ─── in flight
B ─→ refresh ─┼→ 3 at once B ─→ wait
C ─→ refresh ─┘ C ─→ wait
done → share result with B, C
→ collision → all revoked → one server callIt's a common front-end pattern, and the implementation is a few lines that hold the in-flight refresh Promise and hand the same one to later callers.
That's the code I wrote first. And it does not work on our infrastructure.
Where we deployed it
The console is a BFF. The browser never calls the API server directly.
Browser │ (httpOnly cookies — JS cannot read them) ▼ Next.js Route Handler /api/proxy/[...path] │ pulls tokens from cookies, attaches the Authorization header ▼ API server
Tokens live in httpOnly cookies to keep them away from XSS. But then browser JS can't read them, so the server attaches the header instead. Which meant the refresh logic naturally lived in the proxy.
The question is where that Next.js runs. The console sits on a serverless platform, so Route Handlers execute as serverless functions. And in serverless, a module-scope variable is not process-scoped. It's instance-scoped.
[what I imagined] [reality]
one Next.js server each request gets an instance
┌──────────────┐ ┌────┐ ┌────┐ ┌────┐
│ inFlight Map │ │ λ1 │ │ λ2 │ │ λ3 │
│ shared A B C │ │Map │ │Map │ │Map │
└──────────────┘ └────┘ └────┘ └────┘
A B C
→ the lock holds → three empty Maps. All three refreshThree parallel requests can scatter across different instances. Each instance's Map is empty, so each decides "I'm first" and sends a refresh. Exactly the situation before the fix.
Land on the same instance and it works; scatter and it doesn't. A defense that works probabilistically is not a defense. It's arguably worse than none, because local dev is always a single process; the tests pass, and it only leaks in production, sometimes.
Choosing the coordination point again
So I changed the question: where should the lock live?
Look at what the competing requests have in common. The refresh token is in that user's httpOnly cookie. Every request carrying that cookie leaves from one user, one browser. There is no contention with other users at all; their tokens are different.
The scope that needs coordinating is one browser, not the whole server.
Contention scope Coordination option Affected by instance count?
one browser ← server module memory yes (breaks on serverless)
← Redis / KV store no (extra infrastructure)
← the browser no (nothing extra)Put it in the browser and it stops mattering how many servers come up. Serverless, autoscaling, a mid-deploy mix of old and new versions. None of it can reach a lock that lives with the user.
Web Locks
The browser already ships a standard lock: navigator.locks. Within the same origin it is shared across tabs.
const LOCK_NAME = "token-refresh";
let fallbackChain: Promise<unknown> = Promise.resolve();
export async function withRefreshLock<T>(task: () => Promise<T>): Promise<T> {
const locks =
typeof navigator !== "undefined" && "locks" in navigator
? navigator.locks
: null;
if (!locks) {
// Fallback for unsupported environments — serializes within one context only
const run = fallbackChain.then(task, task);
fallbackChain = run.then(() => undefined, () => undefined);
return run;
}
// The callback's return type infers as T, producing Promise<Promise<T>> — await unwraps it
return await locks.request(LOCK_NAME, task);
}Cross-tab sharing matters. Admins routinely keep the console open in two tabs, and two tabs hitting expiry at the same moment is the same race. Redis would have solved the server-instance problem and this scenario too, at the cost of one more piece of infrastructure to run. For an internal tool with a dozen users, that cost didn't fit.
Merging in-flight refreshes was not enough
Then I got stuck again. Even with the lock, walking through the scenario exposed a hole.
New tokens reach the browser via the Set-Cookie header, and that header rides only on the response of the request that performed the refresh. B and C were already in flight; they arrive at the server still carrying the old cookie.
Lock held, but result discarded immediately
A acquires lock → refreshes → success. New cookie rides A's response
└ done. cache cleared
B acquires lock (after A) → looks at the cookie in its own request
└ old refresh token. Cache is empty, so "I'm first"
└ refreshes → token already spent → reuse detected → all revoked ✗The lock is held and the ending is the same. Only the order changed.
So the result now stays around for 30 seconds after completion, keyed by the previous refresh token. A late request carrying the old cookie looks it up by that key and receives the same result.
// Keep the refresh result queryable by the *previous* refresh token for a while
const REFRESH_RESULT_TTL_MS = 30_000;
export function refreshTokensOnce(
refreshToken: string,
refresh: (rt: string) => Promise<TokenResponse>,
): Promise<TokenResponse> {
evictExpired();
const cached = refreshCache.get(refreshToken);
if (cached) return cached.promise; // in flight or settled — same result
const settled: { at: number | null } = { at: null };
const promise = refresh(refreshToken).then(
(tokens) => { settled.at = Date.now(); return tokens; },
(error: unknown) => { settled.at = Date.now(); throw error; },
);
refreshCache.set(refreshToken, { promise, settled });
return promise;
}Failures are shared too. If the refresh token is genuinely expired, a queue of parallel retries achieves nothing, and the retries themselves can trigger reuse detection.
After taking the lock, before refreshing
One more step was needed. While you wait for the lock, another tab may have already finished refreshing. Refresh again at that point and you've just committed the reuse yourself.
So inside the lock, before calling refresh, the original request is retried once. If another tab refreshed, the new cookie is already in the browser and the retry simply succeeds.
got 401 → acquire lock → retry original request ← if another tab refreshed, done. No refresh → still 401 → call the refresh endpoint → retry original request → still 401 → session expired
In code:
async request<T>(path: string, options: RequestInit = {}): Promise<T> {
const response = await this.send(path, options);
if (response.status !== 401) return this.parse<T>(response);
return withRefreshLock(async () => {
// Another tab may have refreshed while we waited for the lock
const retried = await this.send(path, options);
if (retried.status !== 401) return this.parse<T>(retried);
const refreshed = await fetch("/api/auth/refresh", { method: "POST" });
if (!refreshed.ok) return this.expireSession<T>();
const final = await this.send(path, options);
if (final.status === 401) return this.expireSession<T>();
return this.parse<T>(final);
});
}Stripping refresh out of the proxy
Coordination moved to the browser, so the proxy must not refresh. Two places refreshing means two places racing.
The proxy became a pure forwarder. It pulls the token from the cookie, attaches the header, and returns a bare 401 when the token has expired.
// Expired or about to expire: return 401 without calling upstream.
// The browser refreshes and retries, so we skip a round trip that's guaranteed to fail
if (!accessToken || isAccessTokenExpiring(accessToken)) {
return unauthorized("Authentication required");
}isAccessTokenExpiring reads exp from the JWT payload and returns true from ten seconds before expiry. This is expiry reading, not signature verification, so a base64 decode is enough; the API server verifies anyway.
This is what remains of the "proactive refresh" I originally wanted. There's no point shipping a token that's obviously expired all the way to the API server just to collect a 401.
But the 401 never reached the browser
While verifying all of this, I found the thing that nullified all of it.
The middleware was redirecting unauthenticated requests to the login page. Normal route protection. Except the rule also applied to API paths.
// The broken state — the matcher includes /api/proxy/*
if (!accessToken) {
const loginUrl = new URL(ROUTES.LOGIN, request.url);
loginUrl.searchParams.set("redirect", pathname);
return NextResponse.redirect(loginUrl); // a 307, to an XHR
}fetch follows redirects automatically. So this happens:
XHR GET /api/proxy/exercises ↓ middleware: no access cookie → 307 /login ↓ fetch follows the redirect ↓ receives the login page HTML with a 200 ↓ response.json() → HTML parse error The browser never saw a 401. The refresh logic never runs.
And this path is taken unconditionally. Look at the cookies:
`${COOKIE_ACCESS_TOKEN}=${accessToken}; HttpOnly; SameSite=Lax; Path=/; Max-Age=600`
`${COOKIE_REFRESH_TOKEN}=${refreshToken}; HttpOnly; SameSite=Lax; Path=/; Max-Age=3600`The access cookie's Max-Age is 600 seconds, identical to the access token's lifetime. After ten minutes, the token doesn't merely expire; the cookie itself vanishes from the browser. The middleware then rules "not logged in" and redirects.
To summarize:
Elapsed | State | Result |
|---|---|---|
0–10 min | access cookie present, token valid | fine |
10 min + | cookie gone → middleware 307s | no refresh. JSON parse error |
The refresh cookie lives for an hour, so the session is perfectly recoverable, but the 401 that would trigger recovery never reaches the browser. Step away for ten minutes and it reproduces every single time.
The fix is one line. Redirects apply to page navigation only; for APIs, the route handler answers with 401 JSON itself.
// API requests: the route handler decides auth and returns 401 JSON.
// Redirecting to the login page here hands HTML to an XHR,
// the browser never sees a 401, and the whole refresh flow never runs.
if (pathname.startsWith("/api/")) {
return NextResponse.next();
}Verified, the two paths now split:
GET /api/proxy/exercises → 401 {"code":"UNAUTHORIZED", ...} (JSON)
GET /exercises → 307 /login?redirect=%2Fexercises (pages unchanged)What makes this bug interesting is that on its own, its symptoms are vague: "sometimes there's a weird error." And you can fix the token race perfectly, and this bug ensures the fixed code never even executes. The two defects were hiding each other.
I came back from lunch and hit refresh
With all that fixed, I went to lunch. Came back, refreshed the tab I'd left open. Login screen.
The exact scenario I imagined at the top of this post: "an admin leaves the console open, steps away, comes back." The admin was me.
I had definitely exempted the API paths, and it happened anyway. This time it wasn't an XHR. It was page entry being blocked. I read the middleware again.
const accessToken = request.cookies.get(AUTH.COOKIE_ACCESS_TOKEN);
if (!accessToken) {
// to the login screen
}Login state was judged by the existence of the access cookie. But that cookie disappears after ten minutes. The thing that actually determines session lifetime is the refresh cookie (one hour), and the judgment was keyed to the short one.
Elapsed | access cookie | refresh cookie | middleware says | actually |
|---|---|---|---|---|
0–10 min | present | present | logged in | logged in |
10 min–1 h | gone | present (valid) | logged out | logged in |
1 h + |
The middle fifty minutes were wrong wholesale. Ten minutes of idle, one page refresh, and you bounce to the login screen.
I moved the judgment to the refresh cookie. A missing or expired access token is no longer the middleware's problem. The page renders first, and when the first API call takes a 401, the browser refreshes inside the lock.
The cause is different from the API-redirect bug. That one treated APIs like pages; this one judged session lifetime by the shorter token. Both looked like the same symptom.
Two doors bypassed the lock
Then I got logged out again. This time to /session-expired.
The Network tab showed the me request taking a 401 and jumping straight to session expiry. No refresh attempt at all. I went looking.
// AuthProvider — the path every console screen goes through
fetch("/api/proxy/me")
.then((res) => {
if (res.status === 401) {
window.location.href = "/session-expired"; // ← instant expiry, no refreshIt was calling raw fetch directly, not going through fetcher. No lock, no retry, no refresh. And this call goes out first, on every console screen, to load the profile. Since the access cookie vanishes in ten minutes, this path effectively always trips first.
However precise the refresh logic, a single call that doesn't go through it will kill the session before the logic gets a chance.
Sweeping the codebase turned up one more bypass, the session timer's "extend" button.
const res = await fetch('/api/auth/refresh', { method: 'POST' });A refresh called directly, outside the lock. If the user clicks it at the moment an API-triggered refresh is in flight, the exact race we were preventing plays out again. Worse: this button appears when the "session about to expire" warning shows, precisely when other requests are hitting expiry too. A bypass placed at the most collision-prone moment.
Both went inside the lock; the profile call now goes through fetcher, and the extend button is wrapped in withRefreshLock.
A lock is only a lock if every path goes through it. Building a wrapper and enforcing a wrapper are different jobs, and I had only done the first. The type system doesn't catch it either, since fetch is a global function and calling it is syntactically unremarkable.
Verified in the browser
Then I checked it on the real screen. There's no need to wait thirty minutes. Delete just the access cookie in DevTools' Application tab and you're in the same state.
me 401 fetcher.ts:30 ← initial request me 401 fetcher.ts:30 ← retry inside the lock (other-tab check) refresh 200 fetcher.ts:20 ← one refresh me 200 fetcher.ts:30 ← retry succeeds
Two 401s is correct. The second is the retry that checks "did another tab already refresh?" There's exactly one thing to verify: is refresh exactly one request? Two or more, and the session is revoked on the spot.
On the parallel-call screen (the announcements list), four 401s show up and refresh is still one. The lock actually holds.
Two layers of defense
Web Locks is the primary defense, but refreshTokensOnce stays on the refresh endpoint too.
// The browser's Web Lock is the primary defense. This merges concurrent calls
// server-side as well, for lock-less environments or requests that land on one instance
const data = await refreshTokensOnce(refreshToken, (rt) =>
apiClient.postForm<TokenResponse>(TOKEN_ENDPOINT, { ... }),
);It's still instance-scoped, still incomplete; that hasn't changed. But an incomplete defense and no defense are different things. The comment marks it as backup, not primary, so whoever reads this code later doesn't think "this covers it."
Pinned down by tests
jsdom has no navigator.locks, so the tests exercise the fallback path. The lock itself is a browser standard we trust; what we verify is "given serialization, does the right behavior come out?"
it("serializes concurrent tasks so they never overlap", async () => {
let running = 0, maxConcurrent = 0;
const task = async () => {
running++;
maxConcurrent = Math.max(maxConcurrent, running);
await new Promise((r) => setTimeout(r, 5));
running--;
};
await Promise.all([withRefreshLock(task), withRefreshLock(task), withRefreshLock(task)]);
expect(maxConcurrent).toBe(1);
});The refreshTokensOnce tests transcribe the scenario directly. The one that matters most: "after a refresh completes, a request arriving with the old token gets the same result without re-refreshing." That's why the 30-second retention exists, and it's the bug that comes back if the retention ever gets removed.
So I actually measured it
With the client fixed, I wanted to confirm the server really behaves the way I'd described. I wrote a script that deliberately triggers reuse detection (three concurrent requests with the same refresh token) and inspects token state immediately after.
The question was simple: "does the winner's new token get wiped too?"
Token | /me | /exercises | /terms |
|---|---|---|---|
old access from login | 401 | 401 | 401 |
the winner's brand-new access | 200 | 200 | 200 |
The winner's refresh token was revoked correctly (5/5). Only the access token survived. Also 5/5, every time.
One thing snagged during measurement. The auth endpoint is rate-limited, so one of the three concurrent requests got cut off with a 429 every time. The numbers above are really a two-way race measured five times, not a three-way one. It doesn't affect the winner-to-detection gap, but it needs disclosing if you quote the numbers.
The same snag matters in production too. Some parallel refreshes will hit the rate limit first, end as 429s, and never reach reuse detection. Anyone using reuse-detection counts as a metric is reading a number lower than what actually happened.
The winner didn't die
Earlier in this post I wrote "there is no winner" and "the admin is fully logged out." Conclusions from reading the code. Half of them were wrong.
What actually happens is that the refresh token disappears and the access token remains. The user keeps working, untouched, until the access token expires. At that moment there's no refresh token to renew with, and then they're logged out. Up to ten minutes later on the admin console; up to an hour on the apps.
Sounds better than being cut off instantly? It's the opposite. revokeAll exists to respond to theft. If the party you're trying to cut off is the one who just collected the tokens, their access token doesn't get cut.
One truncated second, one comparison operator
The cause was three lines.
// Store the invalidation cutoff truncated to seconds long nowEpoch = Instant.now().getEpochSecond(); // Reject if the token's iat is 'before' the cutoff return tokenIssuedAt.getEpochSecond() < Long.parseLong(value);
When iat == cutoff, < is false, so the token passes. Every access token issued within the same second as the invalidation survives.
And in reuse detection, that condition holds always, because revokeAll runs immediately after the winner's new tokens are issued. This is where the 0.4–2.2 ms from earlier becomes decisive. A millisecond gap cannot escape the second it's in. The 5/5 survival wasn't luck. It was structurally 100%.
JWT iat is second-precision by spec, so raising the cutoff to milliseconds doesn't help; you're still comparing against a truncated value. Within the same second, there is no way to know whether a token was issued before or after the invalidation.
When it can't know, a security mechanism should err on the side of cutting the token, not keeping it alive.
return tokenIssuedAt.getEpochSecond() <= Long.parseLong(value);
The price is that tokens legitimately issued in the same second as an invalidation get rejected too, at most one second's worth. The client refreshes, receives a new token with a later iat, and recovers on its own; worst case, one extra round trip.
The window wasn't unique to reuse detection
I counted the places that call revokeAll.
email change · password change · PIN change · phone number change PIN reset · account deletion · password reset refresh reuse detection
Every one of them had the same window. Right after a password reset, an access token issued within that one second keeps working for its full lifetime. Ten minutes on the console, an hour on the apps, twenty-four hours for the shared-device pre-auth token.
Consider why people usually reset their passwords, and this one hurts more than the reuse-detection case.
Why the tests didn't catch it
Session invalidation had tests. A token issued ten seconds before the cutoff is rejected; one issued ten seconds after passes. Both were passing.
There was no boundary value. Nobody checked iat == cutoff. And this bug lives at exactly that one point.
I added boundary tests at both the unit and integration level, flipped the operator back to <, and confirmed three tests fail. The integration tests use real Redis, not a mock, because the whole question is how second-truncation actually gets stored and compared. Fake the stored value and there's nothing left to verify.
Premises I never verified
I built two wrong premises during this work. Both came from reading code. Both broke only when the code actually ran.
First. "A request with an expired token and a malformed body will get a 400 first, so 401-based refresh won't trigger."
I wrote the server, and the me who wrote the server held the same premise, because authorization (403) really does run after body validation. But when I checked, authentication (401) runs before body validation. A missing or expired token never reaches the body validator. The only case where 400 wins is "authorized but malformed," and that's not a problem refresh can solve. It took sending real requests through MockMvc to surface this. I almost wrote code to guard against a problem that doesn't exist.
Second. "When reuse is detected, every session is cut."
This was the title and the premise of this post. I believed it from the name revokeAll and the code inside it. Measured for real, only the refresh token dies; the access token lives. I almost walked past a problem that does exist.
Opposite directions, same cause. Reading code and running code are different things. Holding both the server and the client in one head doesn't fix this. Owning every line myself didn't make the premises any more verified.
Honestly, the second one was caught by accident. I wrote the script to check my client fix, not because I suspected the server. Having triggered reuse detection anyway, I threw the access token into the check on a whim. That was all.
What's left: the other clients
That covers the admin console. The same structure exists in the other clients.
The constraint applies to every client; the shorter the access lifetime, the more often it fires.
Client | access lifetime | Status |
|---|---|---|
admin console | 10 min | fixed in this round |
data platform web | 1 hour | same mitigation in progress |
member app | 1 hour | to be reviewed |
tablet app | 1 hour | to be reviewed |
With a one-hour access token the frequency is low enough to pass as "huh, it logged me out again." A lower rate doesn't mean it stopped happening.
The native apps have no Web Locks, so the same solution doesn't transfer. But they're single-process, which makes coordination easier, not harder; holding the in-flight refresh is enough. The genuinely hard case was the web, with its multiple tabs.
Why this bug doesn't get caught
Had it shipped, root-causing it would have taken a while. Three things stack up.
- Reproduction is finicky. "Idle for 10+ minutes, then enter a parallel-call screen" almost never happens during development; while you're editing code you're touching the screen constantly, so the token stays warm.
- The symptom is vague. To users it's "it sometimes logs me out." Ask for reproduction steps and there aren't any.
- The logs lie. The server records only
INVALID_TOKEN. Nothing distinguishes real token theft from this race. If anything, it reads as a security event.
Then infrastructure adds one more layer. The local dev server is a single process, where a module-scope lock works perfectly. You get code that is correct in development and wrong in production, and it passes tests, and it passes code review. The difference in execution environment is written down nowhere in the code.
On the server side I added tests that pin this behavior down: only the first-arriving request succeeds, a late request revokes the whole session, there is no grace period. So that when someone eventually says "couldn't we relax this?", it's visible exactly what would be given up.
This defect lives in neither the server nor the client. It lives between them.
Look at the server alone: rotate() is correct, and reuse detection follows the RFC's recommendation. Look at the client alone: parallel calls are a normal optimization, and refreshing on 401 is standard. The middleware's login redirect and the token-length cookie lifetime are reasonable decisions on their own too. Review each piece as long as you like; the bug is not visible in any of them. The defect wasn't in any single decision. It was in the combination.
The combination wasn't all code, either. Where you deploy belongs to it too; the same code was correct on a single server and wrong on serverless. Moving the lock into the browser worked because that is where this race actually lives: one user, one browser.
I saw it because I was holding both sides at once. Split the server and client across two teams, and it gets caught only after "it sometimes logs me out" has bounced back and forth a few times.
This is not an incident postmortem. It's a record of stopping to check before the deploy.
0 comments