By Quang Dang (github.com/dakiquang), a product-minded tech lead with 15+ years building software, 10 of them in SaaS · tested 7 November 2026 · last updated 8 November 2026 · Bản tiếng Việt

Your Laravel queue has eight workers (queue:work or Horizon), and a scheduled command runs at the same time. They all pick up work for the same connected account at the same moment, and the access token has just expired. Each one notices and calls the token endpoint with the same refresh token. That is the OAuth refresh token race condition, and it is behind many "random" invalid_grant errors.

What happens next depends entirely on the provider. With some, nothing visible. With others, the user loses the connection and has to reconnect. I built a small Laravel package for this, laravel-oauth-refresh-lock, and tested it against real QuickBooks Online, Xero and Salesforce accounts (Microsoft and Google not yet). This post goes through each provider, the other causes of invalid_grant, and then the fix.

This is about your app as an OAuth client of someone else's API. If your app issues tokens itself (Passport, Sanctum or a JWT package), that is a different problem; Sanctum has no refresh tokens at all.

Short answer. When several workers refresh one OAuth token at once, providers that rotate refresh tokens may reject or revoke the duplicate. QuickBooks Online and Salesforce can cost you the connection, Xero hides the race behind a 30-minute grace period, and Microsoft and Google just waste a call. The fix: one Cache::lock per connection, a re-read of the token inside the lock, and a compare-and-swap that saves the new access and refresh token in one write.

What is the OAuth refresh token race condition?

The OAuth refresh token race condition happens when two workers send the same refresh token at once to a provider that uses refresh token rotation. Most providers that rotate refresh tokens issue a new refresh token on every refresh and retire the old one. The first one wins. What the second one gets is the provider's choice: a second valid token, an invalid_grant error, or the revocation of the whole connection.

The harshest outcome comes from refresh token reuse detection. RFC 9700 (OAuth 2.0 Security Best Current Practice) describes it: when an already-rotated refresh token is presented, the authorization server cannot tell the attacker from the legitimate client, so it revokes the active refresh token too (some providers call this revoking the token family). A second worker of your own looks exactly like an attacker.

Case studies: refresh token rotation by provider

Provider behaviour comes from each provider's documentation, linked in each section. The "real run" results come from test accounts I created for this: a QuickBooks Online sandbox company, Xero's Demo Company and a Salesforce Developer Edition org, each with a fresh Laravel 12 app.

QuickBooks Online: a duplicate refresh gets invalid_grant and can cost the connection

What the documentation says. Intuit changes the QuickBooks Online refresh token value every 24 hours, and when a new value is issued the previous one expires. If two refreshes use the same value, the first succeeds and the second gets invalid_grant, and Intuit may treat it as a security issue and revoke the refresh tokens. Intuit asks you to refresh one at a time (Intuit OAuth 2.0 FAQ). The FAQ does not say whether a duplicate inside the same 24-hour window also fails; plan as if it does.

Real run. QuickBooks Online sandbox company. Six processes started at the same moment with an expired token, three rounds. In every round one process refreshed and the other five used the token it stored. No invalid_grant; the final token was valid, and the refresh response reported expires_in: 3600. One round, grouped by outcome:

 1 REFRESHED the token, company "Sandbox Company …" 5 used the stored token, company "Sandbox Company …" 6 processes, 1 refresh. 

No run without the lock: it could revoke the sandbox connection. That risk comes from Intuit's FAQ, not from my own test.

Takeaway. This is the case the package is for: without serialisation, a busy queue can disconnect a customer's books.

Code: examples/quickbooks

Xero refresh token rotation: a 30-minute grace period hides the race

What the documentation says. Xero returns a new refresh token on every refresh. If your app does not receive or save the new one, it can retry with the previous refresh token for 30 minutes; after that the previous token expires and the user has to authorise again (Xero token types). Xero does not name the error for a refresh token used after the grace period; OAuth 2.0 defines invalid_grant for an invalid, expired or revoked refresh token.

Two different 30-minute figures are easy to mix up: the access token lasts 30 minutes, and the previous refresh token stays usable for 30 minutes after a rotation. The refresh token itself lasts up to 60 days.

Real run. Xero Demo Company. With the package: six processes, four rounds, one refresh per round.

Then a control experiment, without any lock: six processes refreshed at the same moment with the same refresh token. All six succeeded, and Xero issued a different new refresh token to each of them. So with Xero the race does not show up as an error. Six workers now hold six different refresh tokens; an app that stores what each worker received ends up with whichever one was written last. Whether the other siblings stay valid after 30 minutes is not documented, and I have not measured it.

Takeaway. Xero hides the race, so an integration that ignores it looks fine in testing; the lock avoids creating sibling tokens whose fate after 30 minutes is unknown.

Code: examples/xero (HTTP Basic auth at the token endpoint, tenant from the connections endpoint)

Salesforce refresh token rotation: a short grace, then the connection is revoked

What the documentation says. With Salesforce Refresh Token Rotation on, every refresh returns a new refresh token and the previous one is invalidated at once. Salesforce treats refresh token reuse as a compromise: reusing an invalidated refresh token revokes the current refresh token and its access tokens too, so the user has to authenticate again (Refresh Token Rotation). Rotation is on by default, and cannot be turned off, for new External Client Apps since 22 October 2026 (External Client App defaults).

The token response also has no expires_in: the session timeout is configured in Salesforce, so an app learns that an access token is dead when an API call answers 401. That broke my first design, because a package that refreshes "shortly before expiry" never refreshes a token without an expiry. So the package gained rejected(), for the usual "401, then refresh, then retry" flow:

$pending = OAuthRefreshLock::for($key); $accessToken = $pending->accessToken($refresher); $response = Http::withToken($accessToken)->get($url); if ($response->status() === 401 && $response->json('0.errorCode') === 'INVALID_SESSION_ID') { $accessToken = $pending->rejected($accessToken)->accessToken($refresher); $response = Http::withToken($accessToken)->get($url); } 

rejected() refreshes while the stored access token is still the one that failed, under the same lock. Workers that get the same 401 cause one refresh: the first refreshes, and the others find a different access token stored and use it.

Real run. A Salesforce Developer Edition org with a new External Client App. Setting it up taught two things the documentation does not stress: Refresh Token Rotation, PKCE and a 30-day idle refresh token limit are switched on and locked for new apps, so the authorisation request has to send PKCE; and login.salesforce.com answered invalid_client_id for the brand-new app while the org's My Domain worked at once.

With the package: I replaced the stored access token with one Salesforce would reject, as after a session timeout, and started the processes: six in each of three rounds, then three in a fourth. In every round each process got a 401 and reported it with rejected(); one refreshed and the others used its token. The connection was kept. The refresh responses had no expires_in and a new refresh token every time, as documented.

Then the control experiments, without the lock:

  • Six processes refreshed at the same moment with the same refresh token. Five succeeded, each with a different new refresh token, and one got concurrent_request. All five access tokens worked, and the old refresh token still refreshed a few seconds later. So Salesforce tolerates a reuse for a short moment, which its article on rotation does not mention.
  • I rotated a refresh token once and reused the old one 90 seconds later. invalid_grant, and the current refresh token stopped working too: the whole connection was revoked, exactly as the article says.

How long the tolerance lasts I did not measure; somewhere between a few seconds and 90. A worker that is slow, retries late or holds a stale token falls outside it.

Takeaway. The strictest provider here. Concurrent refreshes may get away with it for a moment, but a late reuse ends the connection, and refreshes have to be driven by 401s (INVALID_SESSION_ID) rather than by an expiry time. The package now also treats concurrent_request as a temporary error; Salesforce's refresh token flow page warns that simultaneous requests with the same refresh token fail intermittently and suggests retrying them.

Code: examples/salesforce

Microsoft and Google refresh tokens: little to lose

Microsoft. Microsoft identity platform refresh tokens replace themselves with a new one on every use, but the old one is not revoked when used (Microsoft refresh tokens). Duplicate refreshes waste calls; they do not break the connection.

Google. Google does not return a new refresh token when you refresh; you keep using the one you have (Google OAuth 2.0 for web server apps). There is no rotation race. Refresh tokens can still stop working for other reasons, listed in the next section.

Takeaway. For these two, serialising refreshes saves calls rather than connections. When a Microsoft refresh token really is dead you get invalid_grant with an AADSTS code such as AADSTS70008 (error codes); with Google, invalid_grant.

Code: examples/microsoft · examples/google

Summary: what a duplicate refresh does, per provider

Provider Old refresh token after a refresh A duplicate refresh Tested
QuickBooks Online expires invalid_grant, may revoke the tokens real sandbox
Salesforce (rotation on) usable for a few seconds, then invalid within seconds: succeeds or concurrent_request; later: revokes the whole connection real Developer Edition org, plus no-lock controls
Xero usable for 30 minutes succeeds and issues another token real Demo Company, plus a no-lock control
Microsoft not revoked wasted call documentation only
Google not rotated wasted call documentation only

Is invalid_grant always a race?

No. OAuth 2.0 uses invalid_grant for any refresh token that is invalid, expired, revoked or issued to another client (RFC 6749, section 5.2). Before blaming concurrency, rule these out (all from the providers' documentation, linked above and in the repo's providers page):

  • The new refresh token was not saved. QuickBooks Online and Salesforce retire the previous value; Xero allows it for only 30 minutes.
  • Unused for too long. QuickBooks Online: 100-day rolling expiry. Xero: up to 60 days. Salesforce: a 30-day idle limit on new External Client Apps. Microsoft: AADSTS70008 or AADSTS700082 after inactivity. Google: six months unused, or 7 days for apps still in "Testing" with external users.
  • Access was revoked. The user disconnected the app, or (Microsoft, AADSTS50173) the grant was revoked, for example after a password reset.
  • Too many tokens. Google invalidates the oldest when one account has more than 100 refresh tokens for the same client.
  • Wrong client or environment. A refresh token from one app's keys sent with another app's keys, such as sandbox keys against production.
  • Salesforce policy and restrictions. An app's refresh token policy can expire refresh tokens immediately, after a fixed time or when unused (OAuth access policies), and Salesforce's error list also gives "IP restricted or invalid login hours" as an invalid_grant cause, without naming a flow. A deactivated user gets its own code, inactive_user.
  • A late reuse after rotation. Salesforce revokes the whole connection.

With Google, many write-ups quote the error_description Token has been expired or revoked.; Google's documentation only says the token "may have expired or has been invalidated".

Race or real revocation? Reading the logs

The error alone does not tell you. These signals do:

  • Two refresh calls for the same connection within seconds. Log the connection key and a timestamp on every refresh call. A race leaves two or more calls close together; a real expiry or revocation leaves one.
  • The refresh token sent had already been rotated. Log a short hash of the refresh token sent and received (never the token itself). If a failing call sent a value that an earlier call had already rotated, it was a race.
  • The provider's own hints. Salesforce answers one of two simultaneous refreshes with concurrent_request. Microsoft puts the AADSTS code in error_description, which separates inactivity (AADSTS70008) from a revoked grant (AADSTS50173).

With the package, each connection should show one TokenRefreshed event per expiry, and TokenRevokedByProvider carries the error code. Neither event carries token values.

The fix: one Cache::lock per token, plus compare-and-swap

The minimal fix is a mutex per connection: a distributed lock (Redis lock, database lock) around the refresh, so only one worker calls the token endpoint and the others wait and use its result. Some call this pattern single-flight. A sketch with Laravel's Cache::lock and your own token model:

$token = Cache::lock("oauth-refresh:{$key}", 30)->block(10, function () use ($key, $provider) { $token = Token::where('key', $key)->firstOrFail(); // read again inside the lock if ($token->expires_at?->isAfter(now()->addMinutes(2))) { // expires_at: nullable 'datetime' cast return $token; // another worker already refreshed } $new = $provider->getAccessToken('refresh_token', ['refresh_token' => $token->refresh_token]); $token->update([ 'access_token' => $new->getToken(), 'refresh_token' => $new->getRefreshToken() ?? $token->refresh_token, 'expires_at' => $new->getExpires() ? now()->setTimestamp($new->getExpires()) : null, ]); return $token; }); 

Give the token columns Laravel's encrypted cast; the package's own table does. That already stops most duplicate refreshes. It is not enough on its own: the lock is a lease, so a refresh slower than 30 seconds lets a second worker in (see below); a write from a stale read can overwrite a newer token; a network blip and a real invalid_grant look the same; and with no expires_in (Salesforce), expires_at is null, so the sketch refreshes on every call instead of on a 401. The package handles those:

$accessToken = OAuthRefreshLock::for("quickbooks:{$realmId}") ->accessToken(new LeagueRefresher($provider)); 
  1. Fast path. Read the stored token; if the access token is valid for longer than the skew (two minutes by default, skew_seconds) and was not reported as rejected, return it without taking a lock.
  2. One lock per token key with Cache::lock, from any Laravel cache store that supports atomic locks (Redis, Memcached, DynamoDB, database).
  3. Read again inside the lock. Workers that waited almost always find the token the first worker stored, and return it without calling the provider.
  4. Compare-and-swap write on a version number. Connects and reconnects are unconditional writes that start from a time-based version after a disconnect, so a worker holding an old read does not overwrite a newer connection.
  5. Classify errors. The network errors the default classifier recognises, and HTTP 408, 429 and 5xx, keep the token and throw TransientRefreshFailure. Only invalid_grant marks it revoked.

Which Laravel lock?

Option Works across servers Covers jobs, web requests and commands Re-reads the token
Cache::lock on Redis, Memcached, DynamoDB or database yes, with a shared store yes if you re-read inside the lock
lockForUpdate() in a database transaction (SQLite ignores it) yes yes yes, but the transaction stays open during the HTTP call to the provider
WithoutOverlapping job middleware yes, with a shared store queued jobs only no
file cache lock no, one server only yes if you re-read inside the lock
array cache lock no, one process only — —

Lock or conditional write? You need both

A popular argument is to use a conditional write instead of a distributed lock. A conditional write ("update only if the version is unchanged") does protect the store, but both workers still call the provider, and the provider sees the second call. In my no-lock runs, Xero issued six sibling refresh tokens and Salesforce answered concurrent_request, then revoked the whole connection on a late reuse. A lock alone has the opposite gap: it is a lease, and the lease can run out mid-refresh. With the default 30-second lease:

0 s A takes the lock, sends RT1 (slow) 30 s lease ends; A's call is still in flight 31 s B takes the lock, re-reads: still RT1, sends RT1 ← provider sees RT1 twice 32 s B gets invalid_grant, records a revocation of that version 35 s A gets RT2; compare-and-swap fails, sees B revoked the version A rotated, stores RT2 

One possible order, where B gets invalid_grant (QuickBooks, Salesforce).

So the package does both: the lock stops the second call, and the version number, which works like a fencing token, keeps the store consistent when the lock is not enough. Whether A's new tokens still work depends on the provider: Salesforce may already have revoked everything after the late reuse. With Xero (within 30 minutes) or Microsoft, B's refresh usually just succeeds; A's compare-and-swap then fails, the store keeps B's tokens, and the provider is left with two token generations. Keep the refresh call's HTTP timeout well below the lease. Details in how it works.

WithoutOverlapping is not a substitute: it covers only queued jobs with the same key and never re-reads the token (see the table above).

Save every new refresh token, in one write

Store the access token, refresh token and expiry from every refresh response together, in one write. Intuit says this outright: keep the latest refresh token value from every response. If you save the access token first and the refresh token in a second write, a crash in between leaves you with a refresh token that has already been retired.

If a response has no refresh_token, as with Google, keep the one you have. The package does this: a refresh without a new refresh token keeps the stored one.

What to do after invalid_grant

  1. Re-read the stored token once. Another worker may have refreshed while your call was in flight, and your refresh token was simply stale. The package does this: if the stored version moved during the call, it returns what the other worker stored instead of marking the token revoked.
  2. Stop retrying. Sending the same dead refresh token again cannot succeed, and with reuse detection it can do more damage. The package marks the token revoked and throws TokenRevoked; later calls throw it straight away without calling the provider.
  3. Ask the user to reconnect. Listen for TokenRevokedByProvider to notify the user or flag the integration. Check the stored token before acting on it, as the README explains.

Refresh before expiry, on 401, or on a schedule?

Refresh shortly before expiry when the provider gives one, and on a 401 when it does not (Salesforce) or when a token dies early. The package does both: the two-minute skew for the first, rejected() for the second.

A scheduled command that refreshes every token before it expires is one more worker. It still collides with jobs that find a token expired at the same moment, so it still needs the lock. It also has nothing to schedule against with Salesforce, whose tokens have no expiry. Jitter on a scheduled refresh spreads the load across connections, but neither jitter nor the two-minute skew stops two workers racing on one connection.

How it was tested

  • A concurrency test forks eight PHP processes that wait at a barrier and then all ask for the same expiring (or rejected) token, for three rounds. It runs with the database lock on SQLite, and in CI with the database lock and the Redis lock on MySQL, three times per job. A control run without the lock must show more than one refresh; otherwise the harness would prove nothing.
  • CI caught a real bug along the way: on Laravel 10, the database cache lock deadlocked on MySQL when many workers competed for one lock row (Laravel 11 and 12 share that lock code, so the fix covers them too). A concurrency error while acquiring the lock now counts as a lost race and the worker keeps waiting; once a refresh has started nothing is retried.
  • Real runs: QuickBooks Online, Xero and Salesforce, as above.

Limits

  • It coordinates one application that shares one cache store. Horizon workers on several servers are coordinated as long as they share that store (Redis, for example). Two different apps using the same OAuth connection are not coordinated.
  • rejected() recognises a completed refresh by the new access token value; a provider that returned the same value again would cause one refresh per caller that reported it.
  • Laravel 10 and 11 are supported on a best-effort basis. Both are past Laravel's security support window and have published advisories that will not be patched, so upgrade if you can (support policy).

Try it

composer require dakiquang/laravel-oauth-refresh-lock league/oauth2-client php artisan vendor:publish --tag=oauth-refresh-lock-migrations php artisan migrate 

Supports Laravel 10–13 on PHP 8.1–8.5 (each Laravel version's own PHP minimum applies). The README covers connecting, refreshing, rejected(), disconnecting, errors and events, and the repo has runnable examples for QuickBooks Online, Xero, Salesforce, Microsoft Graph and Google. Reports from providers I have not tested yet are especially welcome.

A personal open source project, not affiliated with any company or OAuth provider.