OAuth token refresh has a race condition. Fix it with a conditional write, not a distributed lock.
Concurrent refreshes don't just fail. They can disconnect the user entirely. Here are four layers of defense, cheapest first, and why the Redis lock everyone reaches for isn't the one keeping you safe.
Refreshing an access token is one POST. You send grant_type=refresh_token, you get an access token back, you store it. RFC 6749 covers it in one short section, and the first version you write works.
It keeps working until two things happen at once. A web request, a background sync, and a webhook handler all need the same user's token in the same few milliseconds, they all see it expired, and they all refresh. Three correct calls, racing.
Most write-ups stop there, call it a race condition, and reach for a distributed lock. That skips the important question: what actually goes wrong when the race happens? There are two different failures hiding in it, they have different consequences, and they need different fixes. A lock is a mediocre answer to one of them and no answer at all to the other.
The two failures
Failure #1: The lost update. Three workers refresh, three responses come back, all three write to your database. The last write wins, and "last" is decided by network timing, not by which response is newest. A slow worker can write a token that's already been replaced. Now your stored token is stale even though every individual call succeeded. This is annoying and self-correcting: the next request finds an expired token and refreshes again.
Failure #2: Replay detection. This is the one that hurts.
When a provider rotates refresh tokens, using one gives you a new one and invalidates the old. If your second and third workers then present the refresh token the provider already replaced, the provider doesn't see a race. It sees a replayed token, which is what a stolen token looks like.
RFC 9700 §4.14.2 tells authorization servers exactly what to do about that: treat replay as evidence of compromise, revoke the active refresh token for that session, and revoke the access tokens issued from it. Auth0 implements this as invalidating the entire refresh token family, including the token it just issued. So the good worker's brand-new token dies too.
The result isn't three requests failing. It's one user, fully disconnected, needing to re-authorize, caused by your own retry rather than by an attacker.
That asymmetry sets the strategy. Failure one is a data problem you can fix locally. Failure two you cannot fix after the fact at all; you can only avoid making the second call.
Why the lock isn't the fix
The standard advice is a Redis lock on the user-and-provider pair: acquire, re-check expiry inside the lock, refresh, release.
It's not useless. It collapses most concurrent refreshes into one, and that's worth having. But it is not what makes you correct, for a specific and well-documented reason.
Martin Kleppmann's argument against Redlock is that a Redis lock is a lease, not mutual exclusion. It's a timer. If your worker acquires the lock and then hits a GC pause, or the provider's token endpoint takes longer than your TTL, the lease expires while the worker is still running and still believes it holds the lock. A second worker acquires it. Now two workers are refreshing the same token, which is the exact situation the lock was supposed to prevent. You'll never see it in testing, because it only happens when something is slow.
Kleppmann's fix is a fencing token: a value that advances on every acquisition, which the storage layer checks so it can reject a write from a worker that lost the lock without knowing it. Redis locks don't produce one.
For this problem, you already have one.
Layer 1: Don't race in the first place
Before any coordination, remove most of the contention. Two changes.
- Treat the token as expired before it is. If
isExpiredreturns false at the moment you check and the token dies mid-flight, you get the same failure with extra steps. Your skew needs to exceed your longest outbound request plus clock drift between your servers and the provider's. Sixty seconds is the floor; five minutes is a reasonable default. - Refresh ahead of expiry, in the background, with jitter. Refresh at roughly 75% of the token's lifetime rather than at the end of it. The on-demand path then almost never fires, which matters because every layer below has a cost you'd rather pay rarely.
The jitter is not optional. Tokens issued in a burst (a migration, a mass reconnect after an incident) expire in a burst. Refreshing them all at exactly T-15m moves the stampede from your database to the provider's token endpoint, which is where the rate limits are.
Layer 2: Single-flight, inside the process
Concurrency clusters. A Node server handling a burst of requests for one user, or a worker pool draining a queue partitioned by user, produces most of its duplicate refreshes inside a single process. You can collapse those with a promise map and no new infrastructure at all.
This is per-process, so K processes still means up to K concurrent refreshes rather than N concurrent requests. It doesn't solve the problem. It shrinks it by a large constant factor for the price of ten lines, which makes it the best value in the whole stack.
Layer 3: The conditional write
This is the part that makes you correct, and it needs no coordination service.
You already have a transactional database holding the token. Don't coordinate outside it. Make the write conditional on the state you read. Add a monotonic version column and update only if nobody has moved it since:
Zero rows updated means another worker got there first. Re-read the row and use what's there. Don't retry, don't refresh again.
That's the fencing token Kleppmann asked for. version advances on every write and old values never come back, so the storage layer rejects exactly the writes that should be rejected, including one from a worker whose lease quietly expired ten seconds ago.

A version column works with every provider. If yours rotates reliably, WHERE refresh_token = $old is the same trick using data you already have. If yours doesn't rotate, and Google frequently returns no new refresh token at all, that predicate won't discriminate, which is why the explicit version column is the general form.
Layer 3 completely closes failure one. It does not close failure two: two workers can still both call the provider, and the second call can still trip replay detection. Nothing you run in your own infrastructure can guarantee otherwise, because the guarantee has to hold across a network boundary you don't control.
Layer 4: A lease, called what it is
If layers 1 through 3 leave you with more cross-process refreshes than the provider tolerates, add the Redis lease. Just size it and describe it honestly:
- It reduces the probability of a duplicate provider call. It does not eliminate it.
- Losing the lease is a normal outcome. Wait, re-read, use what's there, with capped attempts, backoff, and jitter. Never fail the request, and never recurse without a bound.
- The TTL should cover the provider's realistic worst-case round-trip plus margin. Too short and it expires mid-refresh; too long and a crashed worker blocks that user until it lapses.
- Release in a
finally, and treat release as best-effort. The conditional write is what protects you if the release lands late.
The ordering is the point. The lease is a throughput optimization layered on top of a correctness mechanism. Most write-ups have it backwards, which is how you end up with a system that's correct in staging and disconnects users in production.

The part you don't control
How much of this you need depends entirely on the provider, and the providers don't agree.
Okta's grace period is worth dwelling on. It's the acknowledgment, from the providers themselves, that legitimate clients race, and where a provider offers one, it does more for you than any lock you can write. Where a provider doesn't, layers 1 through 3 are your only defense.
Refresh token lifetimes diverge just as much, and they're the thing teams forget to plan for. Google's expire after six months of inactivity, and after just seven days while your app is in testing status with an external consent screen, which is why integrations work in development and break for real users about a week after launch. Microsoft's default to 90 days for most flows but 24 hours for single-page apps and email one-time-passcode flows, they haven't been configurable since 2021, and a password change invalidates them. A nightly warm-up job covers most of this. The enterprise customer who uses the feature once a quarter is not covered by anything except treating "this grant is gone" as an ordinary, expected state.
When refresh fails anyway
One question comes before everything else: is this grant dead, or is the provider having a moment?
Get it wrong toward "dead" and you prompt users to reconnect every time a provider hiccups. Get it wrong toward "transient" and you retry a grant that will never work again, burying a broken integration in retry noise.
Providers make this genuinely hard. Microsoft returns invalid_grant for expired tokens, disabled users, and changed passwords alike, with the real cause in a sub-error code. So invalid_grant on its own tells you almost nothing; you need a per-provider table mapping codes to terminal or retryable, and you maintain it on the provider's schedule.
Back off and retry the transient branch. Never retry the terminal branch. Specifically, on a confirmed reuse or replay error, don't retry and don't try to quietly recover. Clear the local token state and route the user to re-authorization. That's the outcome RFC 9700 expects, and retrying just burns calls against a grant that's already gone.
What you've signed up for
Refresh-ahead scheduling with jitter. Expiry skew wider than your slowest request. In-process single-flight. A versioned conditional write. Optionally a lease, correctly sized and correctly understood. A per-provider table of rotation behavior, token lifetimes, and error codes. And a standing maintenance commitment, because every one of those provider details changes on their schedule.
That's per provider. Most of it isn't hard so much as easy to get subtly wrong in a way that only shows up under load, months later, as users who mysteriously need to reconnect.
It's also the same work in every codebase that does it. WorkOS Pipes does it once. You configure a provider in the dashboard, your users connect through the Pipes widget, and your backend asks for a token when it needs one:
Pipes refreshes the token if it's expired, serializes concurrent refreshes, and stores what comes back. If the grant has been revoked, the response tells you that, so you can send the user to reconnect instead of guessing from an error string. If the token shouldn't enter your environment at all (an agent sandbox, ephemeral compute, a customer-controlled runtime), Relay makes the provider call for you and returns the response.
Shared development credentials mean you can prototype against a provider before registering an OAuth application. The Pipes documentation has the setup.