TL;DR
The cause: modern refresh tokens are single-use and rotating — the
first successful refresh invalidates the token it was sent with and hands back a new one. When
N requests all notice the access token expired at the same instant, they each POST to the
token endpoint with the same refresh token. One wins; the losers present a token that has
just been revoked and get invalid_grant — or, on providers with reuse detection (Okta,
Auth0, Salesforce), refresh_token_reused, which can revoke the whole token family and
log the user out everywhere.
The fixes (apply all that match your topology):
- In-process single-flight — the first caller starts the refresh and stores the
in-flight
Promise; everyone elseawaits the same promise; clear it infinally. Exactly one refresh per expiry, within one process. - Cross-process lock — when several processes (CLIs, workers, agents) share one
credential file: take an exclusive lock (lock file /
O_EXCL), re-read the token after acquiring the lock and short-circuit if it was already rotated, then write atomically (temp file +rename). - Rotation-merge — when the refresh response omits a new
refresh_token(Google does this), keep the previous one; only replace on actual rotation. - Re-read before failing — on
invalid_grant, re-read the stored token once: a sibling may have already refreshed it, turning a hard failure into a retry that succeeds.
Important distinction: in-process single-flight does not fix the cross-process case, and a cross-process lock alone is heavier than you need inside one process. They solve two different problems — see below.
- The race, step by step
- Why do I get invalid_grant?
- refresh_token_reused & reuse detection
- How do you serialize OAuth token refresh?
- In-process single-flight (with code)
- Does an in-process lock fix it?
- Cross-process lock (with code)
- Rotation-merge & atomic persistence
- Re-read before failing on invalid_grant
- Provider quirks
- Fix checklist
- One library option
- FAQ
The race, step by step
An OAuth2 client holds an access token (short-lived) and a refresh token (long-lived). When the access token expires you POST the refresh token to the token endpoint and get a fresh access token back. With refresh-token rotation — now the default at Okta, Auth0, Microsoft, Salesforce, and recommended by the OAuth 2.0 Security BCP for public clients — that refresh token is single-use: the response also carries a new refresh token, and the one you just sent is immediately invalidated.
The bug appears when more than one request needs a token at the same time. This is the norm, not the exception: a page that fires several API calls in parallel, a worker pool, a job that fans out, or several CLIs/agents sharing one stored credential. Here is the timeline with two callers, A and B:
t0 access token is expired (or within the skew window) t1 caller A reads creds, sees "expired", POSTs refresh_token = R0 t2 caller B reads creds, sees "expired", POSTs refresh_token = R0 // same token! t3 provider processes A: issues access A1 + rotates R0 -> R1, REVOKES R0 t4 provider processes B: R0 is revoked -> 400 invalid_grant (reuse-detecting providers: refresh_token_reused, may revoke the whole R-family)
Both callers did exactly what the textbook flow says. The defect is that they did it
concurrently with a token that only tolerates being used once. The symptoms you actually see:
random invalid_grant, surprise re-authentication, a
corrupted token file (two writers racing a read-modify-write), and the classic
"works locally, fails under load."
Why do I get invalid_grant under concurrent refresh?
Because the loser of the race presents a refresh token that the winner already
rotated away. The provider invalidated R0 the moment it issued
R1 to the first caller; the second caller is now sending a revoked grant, and
invalid_grant is the spec's "this grant is no longer valid" answer.
It is worth being precise, because invalid_grant is overloaded and the fix depends on
which cause you actually have:
Cause of invalid_grant | Concurrency-related? | Fix |
|---|---|---|
| The refresh token was rotated by a concurrent refresh and you sent the old one | Yes — the focus of this guide | Single-flight + (if multi-process) a lock + re-read |
| You overwrote/lost the refresh token (e.g. Google omitted it and you saved the response as-is) | Often surfaces under concurrency | Rotation-merge on persist |
| The user revoked access, changed password, or the token genuinely expired/was idle too long | No | Re-authenticate; this one is terminal |
| Clock skew — token treated as valid past its real expiry, or wrong redirect_uri/code on an auth-code exchange | No | Refresh with a skew margin; fix the request |
invalid_grant reads like "the user is logged out, give up and
re-auth." Under concurrency it usually isn't — it's "a sibling request already refreshed; your copy is
stale." Re-authenticating the user on every concurrency-induced invalid_grant produces
exactly the "surprise re-login" symptom. Re-read before failing.
refresh_token_reused and reuse detection
Some providers go further than just rejecting the old token. Refresh-token reuse detection (Okta, Auth0, Salesforce) treats the presentation of an already-rotated refresh token as a likely token-theft replay — the legitimate pattern (a stolen token used after the real client rotated) is indistinguishable from your innocent race. So the provider does the safe thing: it revokes the entire refresh-token family, invalidating both the thief's copy and yours, and the user is logged out everywhere.
That is why a concurrency bug here is more dangerous than a plain invalid_grant: a single
racing refresh can nuke a working session. On these providers, serializing refresh is not an
optimization, it is a correctness requirement.
How do you serialize OAuth token refresh?
The whole fix reduces to one rule: make exactly one refresh happen, and have every other caller use its result instead of starting their own. There are two scopes, and you may need both:
- In-process (single process, many concurrent tasks): use
single-flight — coalesce concurrent callers onto one shared
Promise. - Cross-process (many processes share one credential): add an inter-process lock with a re-read after acquiring it, plus atomic writes so no reader ever sees a half-written token.
And independently of locking, always merge rotation correctly when persisting, and refresh proactively a little before expiry (a small skew, e.g. 30–60s) so requests don't all hit the cliff at the same instant.
In-process single-flight (with code)
Single-flight is the smallest correct fix for the common case (one process, concurrent calls). The
idea: the first caller to see expiry starts the refresh and stores the in-flight
Promise; every other caller awaits the same promise rather than
kicking off its own refresh; the promise is cleared in a finally so the next expiry can
refresh again. JavaScript's single-threaded event loop makes "check the flag, set the promise"
atomic — there is no lock to take.
let inflight = null; // the single shared refresh promise (null when idle) let creds = loadCreds(); // { access_token, refresh_token, expires_at } const SKEW_MS = 60_000; // refresh ~1 min before real expiry function isValid(c) { return c?.access_token && c.expires_at - SKEW_MS > Date.now(); } async function getValidToken() { if (isValid(creds)) return creds.access_token; // fast path, no refresh // SINGLE-FLIGHT: if a refresh is already running, await THAT one. if (!inflight) { inflight = doRefresh(creds) .then(next => { creds = next; return next; }) .finally(() => { inflight = null; }); // clear so the next expiry can refresh } const refreshed = await inflight; // every concurrent caller awaits the SAME promise return refreshed.access_token; } async function doRefresh(prev) { const res = await fetch(TOKEN_URL, { method: "POST", body: form(prev.refresh_token) }); if (!res.ok) throw new Error(`refresh failed: ${res.status}`); const body = await res.json(); // { access_token, expires_in, refresh_token? } return persist(mergeRotation(prev, body)); // merge + write atomically (see below) }
With 50 callers all hitting getValidToken() on an expired token, exactly one
doRefresh runs; the other 49 await its result. The two details that are easy to get wrong:
- Clear the promise in
finally, notthen— otherwise a failed refresh leaves a rejected promise wedged ininflightforever, and every future call re-rejects with the stale error (a "stuck promise").finallyclears it on both success and failure so the next call retries cleanly. - Store the promise before the first
await— assigninflightsynchronously so a second caller arriving on the next microtask sees it.
Does an in-process lock fix it?
No — not if more than one process shares the same refresh token. An in-process
single-flight (a shared promise, an async mutex, a library's internal lock) coalesces refreshes
within one event loop. Two separate processes each have their own memory and their own
inflight variable, so they cannot see each other's in-flight refresh. If two CLIs, two
workers, two containers, or two agents read the same credential file and both refresh, you are right
back in the race — single-flight did nothing for them.
awaits, a request fan-out) is solved by single-flight. "Concurrent" across
processes (multiple OS processes sharing one token file) needs an inter-process lock. Using
single-flight where you needed a lock is the most common reason the bug "comes back" after you thought
you fixed it.
| Your topology | What you need |
|---|---|
| One process, concurrent async calls / request fan-out | In-process single-flight (+ rotation-merge) |
| One token file shared by multiple CLIs / workers / agents / containers | Single-flight per process + a cross-process lock + re-read + atomic write |
| Many machines sharing one credential (distributed) | A distributed lock (Redis/DB) or a single token-broker service; a local file lock is not enough |
Cross-process lock (with code)
When several processes share one stored credential, you need three things together: an exclusive lock, a re-read after acquiring it (so the process that waited doesn't refresh a token a sibling already rotated), and an atomic write (so a racing reader never sees a half-written file).
1. An exclusive lock via O_EXCL
Opening a lock file with the exclusive-create flag (wx in Node, O_CREAT |
O_EXCL at the syscall level) succeeds for exactly one process and fails with
EEXIST for the rest — a portable mutex that even works across most network filesystems
better than advisory flock. Always write the holder's PID and a timestamp so a stale lock
from a crashed process can be reclaimed.
import { open, readFile, rename, writeFile, unlink } from "node:fs/promises";
const LOCK = TOKEN_PATH + ".lock";
const STALE_MS = 30_000;
async function withTokenLock(fn) {
for (;;) {
let fh;
try {
fh = await open(LOCK, "wx"); // O_CREAT|O_EXCL: only ONE process wins
await fh.writeFile(JSON.stringify({ pid: process.pid, at: Date.now() }));
try { return await fn(); }
finally { await fh.close(); await unlink(LOCK).catch(() => {}); }
} catch (e) {
if (e.code !== "EEXIST") throw e;
if (await lockIsStale(LOCK)) { await unlink(LOCK).catch(() => {}); continue; }
await sleep(50 + Math.random() * 100); // someone else holds it; back off and retry
}
}
}
2. Re-read after acquiring the lock — the step everyone forgets
By the time you get the lock, the process that held it before you may have already done the
refresh. If you blindly refresh anyway, you send a token that was just rotated — the very
invalid_grant you were trying to avoid, only now serialized. Re-read the token
from disk after acquiring the lock and short-circuit if it's already fresh.
async function getValidTokenMultiProcess() {
let creds = await readToken();
if (isValid(creds)) return creds.access_token; // fast path, no lock
return withTokenLock(async () => {
creds = await readToken(); // *** RE-READ inside the lock ***
if (isValid(creds)) return creds.access_token; // a sibling already refreshed -> done
const next = mergeRotation(creds, await doRefresh(creds));
await writeTokenAtomic(next); // atomic persist (below)
return next.access_token;
});
}
3. Atomic persistence (temp file + rename)
A token write must be all-or-nothing. Writing in place lets a concurrent reader catch a truncated
file (the read-modify-write corruption). Write to a temp file, then rename it over the
target: rename is atomic on the same filesystem, so readers see either the old file or the
new file, never a half-written one.
async function writeTokenAtomic(creds) {
const tmp = TOKEN_PATH + "." + process.pid + "." + Date.now() + ".tmp";
await writeFile(tmp, JSON.stringify(creds), { mode: 0o600 }); // 0600: owner-only
await rename(tmp, TOKEN_PATH); // atomic swap
}
Rotation-merge & the "Google dropped my refresh_token" bug
Independent of locking, how you persist the refresh response is its own source of
invalid_grant. Providers disagree on what they return:
- Rotating providers (Okta, Auth0, Microsoft, Salesforce) return a new
refresh_tokenon every refresh — you must save it, or your next refresh uses a revoked token. - Google returns a
refresh_tokenonly on the first authorization; refresh responses omit it entirely. If you overwrite your stored credentials with the refresh response as-is, you erase the refresh token and force a full re-consent.
The single correct rule that handles both — rotation-merge: if the response carries
a refresh_token, use it; if it doesn't, keep the previous one.
function mergeRotation(prev, res) {
const merged = { ...res };
// provider omitted a new refresh_token (Google) -> keep the old one
if (!merged.refresh_token && prev?.refresh_token) {
merged.refresh_token = prev.refresh_token;
}
// normalize expires_in (seconds) -> expires_at (ms epoch)
if (merged.expires_in && !merged.expires_at) {
merged.expires_at = Date.now() + merged.expires_in * 1000;
}
return merged;
}
Naive overwrite (save(res)) silently works for rotating providers and silently breaks
Google. Naive "always keep the old refresh_token" silently works for Google and silently breaks
rotation. Merge is the only rule that is correct for both.
Re-read before failing on invalid_grant
Even with single-flight, a race can still slip through across processes, after an
invalidate(), or at a deploy boundary. So make invalid_grant handling
self-healing: before you surface it to the user as "log in again," re-read the stored
token once — a sibling may have just refreshed it.
try {
return await getValidToken();
} catch (e) {
if (isInvalidGrant(e)) {
const fresh = await readToken(); // did someone else just refresh?
if (isValid(fresh)) return fresh.access_token; // yes -> recover silently
}
throw e; // genuinely revoked -> now re-auth
}
This converts the common concurrency-induced invalid_grant into a transparent retry, and
reserves the disruptive re-login for the case where the grant is actually gone (user revoked,
password changed, token idle-expired).
Provider quirks (verify against current docs)
The same code path hits subtly different provider behaviour. A quick map of the ones that bite:
| Provider | Rotates refresh token? | Reuse detection? | The gotcha |
|---|---|---|---|
| No | No | Omits refresh_token on refresh → you must keep the old one (merge), or you force re-consent. | |
| Okta | Yes (default) | Yes | Each refresh returns a new token; reusing the old (a race) → invalid_grant. Single-flight is essential. |
| Auth0 | Yes | Yes | Rotation + automatic reuse detection; a concurrent refresh trips it and can revoke the family. |
| Salesforce | Yes | Yes | A reused token can revoke the whole chain. Never refresh concurrently. |
| Microsoft (Entra) | Yes | No | Identity platform rotates refresh tokens; persist the new one atomically every time. |
| GitHub | OAuth apps: no | No | OAuth-app tokens are non-expiring and the same token comes back; GitHub Apps use expiring user tokens with rotation. |
Behaviour reflects documented defaults as of mid-2026 and can be reconfigured per tenant/app; verify against each provider's current docs before relying on it.
Fix checklist
In order of leverage. Items 1–3 fix the single-process case (most reports); 4–6 add the
multi-process case; 7–8 are correctness hygiene that prevents the other
invalid_grant causes.
- Refresh proactively with a skew (30–60s before expiry) so callers don't all hit the cliff at once.
- Single-flight in-process — one shared in-flight
Promise; all concurrent callers await it. - Clear the in-flight promise in
finallyso a failed refresh doesn't wedge a stuck/rejected promise. - If a credential is shared across processes, take an exclusive lock (lock file /
O_EXCL) around refresh+persist. - Re-read the token after acquiring the lock and short-circuit if a sibling already rotated it.
- Persist atomically — temp file +
rename, mode0600; never write the token file in place. - Rotation-merge on persist — keep the previous
refresh_tokenwhen the response omits one (Google); save the new one when it rotates. - Re-read before failing on
invalid_grant— recover silently if a sibling already refreshed; only re-auth when the grant is genuinely gone.
One library option (honest)
You can implement everything above yourself — the patterns are small and the code on this page is complete enough to copy. If you'd rather not re-derive it, refresh-guard is a small, MIT, zero-dependency library that packages the in-process single-flight + correct rotation-merge + atomic file persistence as one installable primitive, with a typed provider-quirks table for the gotchas above.
If you want the in-process pattern as a drop-in
npm i refresh-guard
import { createTokenManager, fileStore } from "refresh-guard";
const tokens = createTokenManager({
provider: "google", // optional: picks a quirks profile
store: fileStore("~/.myapp/creds.json"), // atomic temp-file + rename persistence
refresh: async (prev) => {
const r = await fetch(TOKEN_URL, { method: "POST", body: form(prev.refresh_token) });
return await r.json(); // { access_token, expires_in, refresh_token? }
}
});
// Call from anywhere, as often as you like — exactly ONE refresh happens:
const accessToken = await tokens.getValidToken();
Disclosure & honest scope: refresh-guard is built by the
same people who wrote this guide, and we list it because it's exactly the shape this page recommends
— not because it's the only way. It solves the in-process case
(single-flight) plus rotation-merge and atomic persistence. It does not ship a
cross-process lock — if you share one credential across processes,
combine it with the lock-file pattern above, or use a self-written cron/broker. Source:
github.com/wartzar-bee/refresh-guard
· package: refresh-guard on npm. The patterns work with any OAuth client or no
library at all.
FAQ
Why do I get invalid_grant under concurrent OAuth2 token refresh?
Your refresh token is single-use and rotating. When several requests hit an expired
access token at once, each POSTs the same refresh token. The first refresh succeeds and the
provider rotates (revokes) that token; the other in-flight requests then present the now-revoked token
and get invalid_grant. On Okta/Auth0/Salesforce, reuse detection may report
refresh_token_reused and revoke the whole token family. Fix: make exactly one refresh
happen and have everyone else await its result.
How do you serialize OAuth token refresh?
In one process, single-flight: the first caller starts the refresh and stores the
in-flight Promise; every other caller awaits that same Promise; clear it in finally.
Across processes (CLIs/workers/agents sharing a credential file), add an exclusive lock
(O_EXCL lock file or flock), re-read the token after acquiring the lock and
short-circuit if it was already rotated, refresh only if still stale, and write atomically
(temp file + rename).
Does an in-process lock fix concurrent refresh across processes?
No. In-process single-flight only coalesces refreshes within one process and event loop. Two separate processes each have their own memory and their own in-flight Promise, so they can't see each other's refresh. For the multi-process case you need an inter-process lock plus a re-read after acquiring it and atomic writes. They solve two different problems; use both when one credential is shared across processes.
Why does Google drop my refresh_token after a refresh?
Google returns a refresh_token only on the first authorization; refresh
responses omit it. If you overwrite stored credentials with the refresh response as-is, you erase the
refresh token and force a re-consent. Fix: rotation-merge — when the response omits
refresh_token, keep the previous one; replace it only when the provider actually rotates.
What is refresh_token_reused and how do I avoid it?
It means the provider saw an already-rotated refresh token presented again — a pattern that also describes a stolen, replayed token — so it treats it as a security event and often revokes the whole refresh-token family. The usual innocent cause is concurrency. Avoid it by serializing refresh (single-flight in-process, a lock across processes), persisting the rotated token atomically before any other caller can read a stale copy, and re-reading after acquiring a cross-process lock.
Should I refresh the token proactively before it expires?
Yes — refresh a little early using a small skew (30–60s). Treating the token as expired slightly ahead avoids the cliff where many requests discover expiry at the same instant, and absorbs clock skew. Proactive refresh reduces but doesn't remove the need for single-flight: you still want exactly one refresh when the skew window opens.
Will a mutex or async-lock library work instead of a shared Promise?
Yes, for the in-process case any of them work — a mutex/async-lock guarding the refresh-and-persist section achieves the same coalescing as the shared-Promise pattern; the Promise approach is just the lightest with zero dependencies. None of them help across processes; that still needs a file/OS/distributed lock.
Is single-flight enough on its own?
Inside one process, single-flight plus rotation-merge is the complete fix and you don't need a lock file. Add the cross-process lock only if a credential is genuinely shared across processes, and a distributed lock or a token-broker service if it's shared across machines.