Handling Token Refresh Races in a BFF

A page that issues eight parallel API calls will notice an expiring access token eight times, and with refresh-token rotation and reuse detection enabled the result is not eight refreshes but one refresh and a revoked token family. This page is part of the BFF pattern for SPAs guide.

Why Rotation Turns a Race Into a Logout

Without rotation a duplicate refresh is wasteful and harmless. With rotation — which you want, because it makes theft detectable — the second presentation of a refresh token that has already been consumed looks exactly like an attacker replaying a stolen one.

Two parallel requests refreshing the same token Both requests read the same refresh token and exchange it; the first succeeds and consumes it, the second presents a consumed token, which reuse detection treats as theft and revokes the whole family. Request A exchanges RT₁ Request B exchanges RT₁ too succeeds RT₁ consumed reuse detected already consumed Family revoked user signed out
The user is signed out for being active, which is the worst possible failure mode and the reason teams disable rotation a week after enabling it.

The Primary Fix: One Refresh Per Session

Serialise refreshes with a lock scoped to the session. The first request to notice an expiring token acquires the lock and performs the exchange; the others wait briefly and then use the result.

export async function freshAccessToken(session: Session): Promise<string> {
  if (!expiringSoon(session.accessToken)) return session.accessToken;

  // Lock per session — never globally, or one user's refresh blocks everyone.
  const lock = await locks.acquire(`refresh:${session.id}`, { ttlMs: 5_000, waitMs: 3_000 });
  try {
    const current = await sessions.reload(session.id);       // someone may have refreshed already
    if (!expiringSoon(current.accessToken)) return current.accessToken;
    return await rotateAndStore(current);
  } finally {
    await lock.release();
  }
}

Two details make this correct rather than merely locked. Re-read the session after acquiring the lock, because the whole point is that another request may have refreshed while you waited. And bound both the lock’s lifetime and the wait, so a crashed holder cannot block a session indefinitely and a slow provider cannot pile up waiting handlers.

The Fallback: A Short Grace Window

A distributed lock is not always practical — several BFF instances, a serverless runtime with no shared coordination, or simply a codebase where adding one is disproportionate. The alternative is to make a brief overlap safe on the server side.

Accept the previous refresh token for a few seconds after rotation, returning the same newly issued pair rather than rotating again. A genuine race resolves silently; a replay minutes later still trips the alarm because it falls outside the window.

A grace window separating a race from a replay A second presentation within a few seconds of rotation returns the same new pair, while a presentation after the window is treated as reuse and revokes the family. rotation grace — same pair returned after the window — treated as reuse
Keep the window in seconds. Long enough for a page's parallel calls, short enough that a captured token is still detectable.

Note that the grace window is a property of whatever holds the refresh tokens. If that is your own store, you can implement it directly; if the provider owns rotation, check whether it offers one, because some do and the behaviour differs.

Refresh Before You Need To

Both fixes are cheaper if fewer requests notice an expiry at the same moment. Refresh proactively when less than a minute of validity remains rather than reactively on a failure, and the burst mostly disappears.

Add jitter to the threshold — a random few seconds per session — so that sessions created together do not all refresh together. Without it, a deploy or a login surge produces a synchronised refresh spike an hour later that looks inexplicable in the metrics and can hit a provider’s rate limits.

Never refresh on a timer that runs independently of requests. A background interval keeps sessions alive for users who left hours ago, which quietly defeats your idle timeout and multiplies the load for no benefit.

Proactive Refresh in Practice

Reactive versus proactive refresh timing Reactive refresh waits for a request to fail with an unauthorised response and then retries, while proactive refresh renews shortly before expiry so no request ever fails. Reactive 401, then retry Proactive refreshed before use expiry
Refreshing in the final minute means no user-visible request ever meets an expired token, which removes most of the contention before locking is needed.

Distinguishing a Race From Real Theft

Both look identical at the point of detection, and reacting identically is correct — revoke the family, because you cannot tell. What should differ is the diagnosis, and that requires context on every exchange.

Record the client address, the user agent and the timing of each refresh. Two presentations from the same address within a second are a race; a presentation from a different address twenty minutes later is theft. Without that context every reuse detection looks the same, and a team drowning in false alarms will eventually switch the alarm off — which is how the control gets lost.

Track the two outcomes as separate metrics: lock waits, which should be common and harmless, and reuse detections, which should be rare. If detections rise while waits stay flat, something is genuinely replaying tokens; if both rise, a client has started refreshing outside your shared path.

Rolling the Fix Out Safely

If rotation is already enabled and users are being signed out, resist the temptation to disable it — that removes your theft detection at the moment you have evidence something is wrong with the refresh path.

Add the lock or grace window first, deploy it, and watch the reuse-detection rate. It should fall to near zero within one token lifetime. Only then consider whether the residual detections are real, and investigate those individually with the context you are now recording.

Keep a switch that puts reuse detection into log-only mode without a deploy. The first genuine incident after enabling enforcement will arrive at an inconvenient hour, and being able to separate “our client is misbehaving” from “someone is replaying tokens” quickly is worth the small amount of configuration it costs.

Testing the Race Deliberately

A race that only appears under production concurrency is one you cannot fix confidently, so reproduce it in a test rather than waiting for it.

Write a test that expires the access token, then issues ten concurrent requests through the BFF and asserts three things: exactly one refresh reached the provider, all ten requests succeeded, and the session still holds a valid token afterwards. That single test covers the lock, the re-read after acquiring it, and the wait behaviour, and it fails loudly if any of the three is removed later.

Add a second test for the grace path if you implement one: refresh, then present the previous token immediately and assert the same pair is returned, and present it again after the window and assert the family is revoked. The two assertions together are what stop the window from silently becoming either useless or permanent.

Run both against a provider stub rather than a real tenant, so they are fast enough to sit in the normal suite. The behaviour you are testing is yours — the coordination — not the provider’s, so a stub that counts exchanges is sufficient and far more reliable than a network dependency in continuous integration.

Finally, include a test that asserts a failed refresh does not retry. It is a small assertion and it prevents the loop that turns a revoked family into sustained load against the provider while the user sits on a spinner.

Frequently Asked Questions

Should the lock be global or per session?

Per session, always. A global lock serialises every user’s refresh through one critical section, which turns a correctness fix into a throughput problem and makes one slow provider response block everybody. The contention you are resolving is between one user’s parallel requests, so the lock belongs at exactly that scope.

How long should the grace window be?

A few seconds — five is generous. It only needs to cover the parallel requests a single page issues at once, and every additional second is time in which a genuinely stolen token would be accepted. If you find yourself wanting thirty seconds, the real problem is that refresh is happening reactively on failure rather than proactively before expiry.

What should a waiting request do if the lock times out?

Fail that request with a retryable error rather than attempting its own refresh, and let the client try again. Proceeding without the lock recreates the race the lock exists to prevent, and doing so under load — exactly when timeouts happen — is when it will do the most damage.

Does this apply to bearer-token clients too?

Yes, and it is harder there because the coordination has to happen in the client. A mobile application or single-page app refreshing from several places needs a single shared promise so parallel calls await one exchange. Moving the refresh behind a backend-for-frontend is partly attractive because it puts that coordination somewhere you can implement it properly.

Can I just retry the failed refresh?

No — a refresh that failed because the token was already consumed will fail again, and if reuse detection has already revoked the family the retry loop hammers the provider while the user waits. Treat invalid_grant as terminal: stop, clear the session, and send the user to sign in.

With the lock, proactive refresh and those tests in place, rotation stops being the feature people disable and becomes what it was meant to be: a quiet mechanism that only speaks up when something is genuinely wrong.