Blog
How Do I Refresh OAuth 2.0 Tokens at Scale
Dev Tips

How Do I Refresh OAuth 2.0 Tokens at Scale without Race Conditions Across Parallel Requests?

Master OAuth 2.0 token refreshes at scale. Avoid concurrency bugs, handle token rotation safely, and implement distributed locks correctly in production.
Oct 16, 2026
Brian Munz
Brian MunzDeveloper Advocate
How Do I Refresh OAuth 2.0 Tokens at Scale
Key takeaways
  • Refresh-token rotation turns token expiry into a concurrency bug. When twenty workers hit one customer's connection as the token expires, they all present the same refresh token, and only the first succeeds.
  • Early refresh falls short. Refreshing at 55 minutes still lets twenty workers discover the refresh window at the same moment.
  • Refresh becomes a per-credential critical section. Scope a distributed lock to a key like tenant and connection ID, re-read the token after acquiring the lock to skip a refresh another worker already did, then persist the access token, refresh token, and expiry in one atomic update.
  • Lock leases need owner tokens and fencing. A TTL can expire while the first worker is still running, so release only when the owner token matches and use a fencing token or version check to stop a stale worker from overwriting newer state.
  • A crash after refresh strands the rotated token. If the provider rotates the refresh token and the process dies before saving it, a later worker retries with the dead token. Use a grace period or idempotency where the provider supports it, and don't treat every invalid_grant as an immediate reauthorization.

The setup was ordinary. An integration called a third-party API on behalf of a customer, using an OAuth 2.0 access token that expired every hour and, for this authorization flow, a refresh token to get a new one. A developer wrote the "if expired, refresh" logic, tested it locally, watched it work, and moved on.

Then the integration went live for several customers.

Instead of one request arriving every few seconds, a burst of concurrent requests hit a single customer's connection right after the token expired. Several requests failed. Some returned 401s. One returned a provider-specific refresh-token reuse error, often represented at the OAuth layer as invalid_grant. A couple of users were logged out of the integration and had to reauthorize.

None of this showed up in connection testing. Unfortunately, production does not care what happened in testing.

Why this occurs

Access tokens are intentionally short-lived. In an OAuth flow that issues refresh tokens, the client can exchange a refresh token for a new access token when the current one expires or becomes invalid.

The authorization server may also return a new refresh token. Some providers rotate refresh tokens on every use and invalidate the previous one as part of that rotation.

That rotation can turn "token expired" into a concurrency bug.

Imagine twenty workers all hit the same customer's connection as the token expires:

12345
Worker A ─ reads token ─ expired
Worker B ─ reads token ─ expired
Worker C ─ reads token ─ expired
. . .
Every worker calls the token endpoint with the same refresh token.

Strict refresh token rotation without a grace period causes race conditions during concurrent requests. If multiple requests use the same refresh token simultaneously, only the first will succeed – and subsequent requests will fail because their token is instantly invalidated.

In addition, providers handle reuse differently:

  • Rejection – Some reject duplicate refresh attempts.
  • Revocation – Others (like Auth0) treat reuse as a security breach and revoke the entire token family, assuming that token was compromised.

When that happens, even the worker holding the newest token may fail, and the user may need to authorize again.

Unfortunately, this issue shows up for high-traffic, high-concurrency scenarios. The moment the system needs to hold up, it falls over.

It almost seems as though it waits for your biggest customer's busiest day and then springs the trap.

Two fixes that are not enough by themselves

Refresh a little early

Refreshing at 55 minutes instead of 60 is a good idea for other reasons, but it does not solve the concurrency problem.

Twenty workers can discover that a token has entered its refresh window just as easily as they can discover that it has already expired.

Early refresh reduces the chance that an in-flight request starts with a token that is about to expire. It does not make multiple refresh calls safe.

Use an in-memory lock

An in-memory mutex works if every request that can refresh a given credential is guaranteed to run in the same process.

That guarantee usually disappears when an application runs across multiple workers, containers, hosts, or serverless invocations. Each process has its own lock, so separate processes can still refresh the same credential concurrently.

The problem is shared state

This is an OAuth problem and a shared-state problem.

OAuth defines the token exchange, but your application still has to coordinate access to the stored credential state. The refresh operation needs to behave like a critical section: one execution refreshes a given credential at a time, and other executions use the result instead of repeating the refresh.

A rough pattern looks like this:

1234567891011121314151617181920212223242526272829303132
def get_valid_token(connection_id):
token = db.get_token(connection_id)
if not token.needs_refresh():
return token.access_token
with distributed_lock(
f"refresh:{connection_id}",
lease_seconds=30,
):
# Another worker may have refreshed the token while we waited.
token = db.get_token(connection_id)
if not token.needs_refresh():
return token.access_token
refreshed = auth_provider.refresh(token.refresh_token)
# A provider may omit refresh_token when it has not changed.
refresh_token = refreshed.get("refresh_token") or token.refresh_token
# Persist the credential state as one atomic update. In production,
# use version checks or another concurrency safeguard as appropriate.
db.save_token_atomically(
connection_id=connection_id,
access_token=refreshed["access_token"],
refresh_token=refresh_token,
expires_at=refreshed["expires_at"],
expected_version=token.version,
)
return refreshed["access_token"]

This is pseudocode, not a complete lock implementation. The important parts are the second read, the per-credential scope, and the atomic credential update.

As you build the fix

Use coordination shared by every worker

Use a coordination mechanism that every process can access. Redis leases, database locks, and other approaches can work, but their failure semantics differ.

The tool you choose matters less than the guarantees it must provide: mutual exclusion, bounded waiting, crash recovery, safe release, and protection against stale lock holders.

Scope the lock to the credential

Do not create one global OAuth refresh lock for the entire system.

Customer A's Acme connection should not block Customer F's Acme connection. Use a key that identifies the shared OAuth state, such as:

1
refresh:{tenant_id}:{connection_id}

If multiple connection records can reference the same OAuth grant, scope the lock to the grant or credential record instead of assuming that a connection ID is sufficient.

Re-read after acquiring the lock

The first read only tells you that a refresh looked necessary before you acquired the lock.

Another worker may have refreshed the token while you were waiting. Re-read the credential after acquiring the lock, then skip the refresh if the stored token is current.

You need that second check to stop sequential refreshes.

Persist the new state atomically

Store the access token, refresh token, expiration, and any provider-specific metadata in a single atomic update. Do not expose a partially updated credential to another worker.

Also, do not replace a valid stored refresh token with null because the provider omitted refresh_token from the response. OAuth allows the authorization server to issue a new refresh token optionally; if it doesn't, the client should retain the existing one.

Treat a lock lease as a lease

For a lease-based lock such as a Redis lock:

  • Set an expiration so a crashed worker does not hold the lock forever.
  • Give each lock acquisition a unique owner token.
  • Release the lock only if the owner token still matches.
  • Extend the lease when a refresh can legitimately run longer than the initial lease.
  • Stop or fail the protected operation if the worker discovers that it lost the lease.

A TTL is not a magic guarantee of mutual exclusion. If the lease expires while the first worker is still running, another worker may acquire the lock. A stale worker must not be allowed to overwrite newer state. For high-value workflows, use fencing tokens or an equivalent version check at the write boundary.

Database advisory locks have different semantics. For example, PostgreSQL advisory locks are tied to a session or transaction and are automatically released when that scope ends; they do not use a Redis-style TTL in the same way.

Handle the crash-after-refresh case

There is a failure window that can occur between two systems:

  1. Provider accepts the refresh request and rotates the refresh token.
  2. Your process crashes, times out, or otherwise loses the response before saving the new token.

When this happens, a later worker may retry with the old refresh token.

To address this (and where the provider supports it), use a short reuse grace period or an idempotency mechanism. Otherwise, record enough state to distinguish an uncertain refresh from a normal invalid refresh, and make the recovery behavior explicit. Do not assume every invalid_grant means the user must immediately reauthorize, but do not retry a dead refresh token forever either.

Refresh before expiry, but not instead of locking

If the provider supplies an expiration value, build in a buffer instead of waiting until now >= expires_at.

A one- or two-minute buffer is often reasonable for a one-hour token, but the correct buffer depends on token lifetime, request duration, and provider behavior.

The buffer reduces the chance that an in-flight request starts with a token that expires during the call, but it does not remove the need for coordination.

Handle an unexpected invalid access token

Expiration timestamps are useful, but they are not guarantees. Tokens can be revoked, provider sessions can be terminated, and clocks can disagree.

If a resource server clearly reports that the access token is invalid (for example, with a 401 response containing the OAuth Bearer error invalid_token, run the request through the same locked refresh path.

After acquiring the lock, check whether another worker has replaced the rejected token. If so, use the newer token. Otherwise, refresh the current credential.

Retry the original request at most once, and only when the provider's response indicates that refreshing is appropriate. Do not refresh blindly on every 401 since 401 may reflect malformed authentication or another specific condition. Be especially careful replaying non-idempotent requests; use provider-supported idempotency keys when a retry is problematic.

If the retry fails, surface a connection error instead of looping.

Now multiply that by every customer

If you call one API for your own company's single account, this pattern is annoying to write once and then maintain.

Embedded integrations don't offer the same luxury. You are managing one connection per customer, multiplied by every provider you support, each with its own credential lifetime, rotation behavior, error format, rate limits, and edge cases.

In a multi-tenant environment, each customer's credential lifecycle needs to be isolated. Customer A's Acme connection should not affect Customer F's Acme connection.

You are now maintaining a credential lifecycle system.

How Prismatic handles this

Teams encounter this concurrency problem when they manage OAuth connections on someone else's behalf.

Prismatic's OAuth 2.0 service handles authentication URLs, token exchange, token refresh, and making current credentials available to integration instances. That means your integration code doesn't need to implement the platform's refresh coordination, credential persistence, or provider-specific refresh behavior.

There is one important execution detail: a token refresh is not necessarily injected into an execution that is already holding an older token. If an in-flight request receives a clear invalid-token response, the integration may still need to retrieve the latest connection token, update its HTTP client, and retry the request once.

The pattern you need if you do things yourself

If you build this yourself, the durable pattern is:

  • Coordinate refreshes per credential.
  • Check the token before locking and re-check it after locking.
  • Write the complete token state atomically.
  • Preserve an existing refresh token when the provider omits it.
  • Define lease expiry, ownership, and stale-holder behavior.
  • Handle uncertain provider responses and refresh-token reuse.
  • Refresh early, but don't mistake an early-refresh buffer for concurrency control.
  • Retry a clearly invalid access token one time at most.

The race condition does not care who solves it or how it is solved. But your customers will care if it's not addressed before they have to deal with the unexpected results.

Get a Demo

Ready to make your product extensible?

Join teams from Fortune 500s to high-growth startups that turned integrations into a growth driver and made their products the foundation that customers build on.