---
title: "Гонка при оновленні OAuth refresh-токена в Laravel: звідки береться invalid_grant"
url: https://laravelukraine.com/blog/gonka-pri-onovlenni-oauth-refresh-tokena-v-laravel-zvidki-beretsia-invalid-grant
date: 2026-11-13
source: https://dev.to/dakiquang/laravel-oauth-refresh-token-race-condition-fixing-invalidgrant-with-quickbooks-xero-salesforce-1ea1
---

# Гонка при оновленні OAuth refresh-токена в Laravel: звідки береться invalid_grant

Це переказ статті [Laravel OAuth refresh token race condition](https://dev.to/dakiquang/laravel-oauth-refresh-token-race-condition-fixing-invalidgrant-with-quickbooks-xero-salesforce-1ea1) Куанга Данга (Quang Dang), опублікованої на dev.to 7 листопада 2026 року. Ми зберегли структуру й технічні висновки автора, але скоротили частину деталей.

Уявіть: у вашої черги вісім воркерів (`queue:work` або Horizon), і паралельно працює запланована команда. Усі вони водночас беруться за роботу для того самого підключеного акаунта, а токен доступу щойно прострочився. Кожен це помічає і йде на token endpoint з тим самим refresh-токеном. Це і є гонка (race condition) при оновленні refresh-токена, і саме вона стоїть за багатьма «випадковими» помилками `invalid_grant`.

Що буде далі, залежить від провайдера: в одних - нічого помітного, в інших користувач втрачає підключення і мусить авторизуватися заново.

Ідеться про ваш застосунок як OAuth-*клієнт* чужого API. Якщо токени видає сам застосунок (Passport, Sanctum чи JWT-пакет), це інша задача, а в Sanctum refresh-токенів немає взагалі.

**Коротко.** Коли кілька воркерів одночасно оновлюють один токен, провайдери з ротацією refresh-токенів можуть відхилити або відкликати дубль. QuickBooks Online і Salesforce можуть коштувати вам підключення, Xero приховує гонку за 30-хвилинним пільговим періодом, а Microsoft і Google просто отримують зайвий виклик. Рішення: один `Cache::lock` на кожне підключення, повторне читання токена всередині блокування і запис із порівнянням версії (compare-and-swap), який зберігає новий токен доступу та refresh-токен однією операцією.

### Що таке гонка при оновленні refresh-токена

Гонка виникає, коли два воркери одночасно надсилають той самий refresh-токен провайдеру, що використовує ротацію refresh-токенів (Refresh Token Rotation): на кожне оновлення він видає новий refresh-токен і списує старий. Перший запит виграє. Що отримає другий - вирішує провайдер: ще один валідний токен, помилку `invalid_grant` або відкликання всього підключення.

Найжорсткіший варіант дає виявлення повторного використання (Refresh Token Reuse Detection). Його описує [RFC 9700](https://datatracker.ietf.org/doc/html/rfc9700) (OAuth 2.0 Security Best Current Practice): коли надходить уже ротований refresh-токен, сервер авторизації не може відрізнити зловмисника від легітимного клієнта, тому відкликає і чинний refresh-токен (дехто називає це відкликанням усієї «родини» токенів). Ваш власний другий воркер виглядає точнісінько як зловмисник.

### Як поводяться провайдери

Таблиця нижче - **стан на листопад 2026 року**, за документацією провайдерів і тестами автора на реальних акаунтах (пісочниця QuickBooks Online, Demo Company у Xero, Developer Edition org у Salesforce). Провайдери змінюють свої правила, тож перш ніж будувати на цьому логіку, звірте з їхньою актуальною документацією.

| Провайдер | Старий refresh-токен після оновлення | Що робить дубль | Перевірено |
|---|---|---|---|
| QuickBooks Online | спливає | `invalid_grant`, можливе відкликання токенів | на пісочниці |
| Salesforce (з ротацією) | діє кілька секунд, потім недійсний | у межах секунд - успіх або `concurrent_request`; пізніше - відкликання всього підключення | на реальному org |
| Xero | діє ще 30 хвилин | успіх і ще один новий токен | на Demo Company |
| Microsoft | не відкликається | зайвий виклик | лише документація |
| Google | не ротується | зайвий виклик | лише документація |

Кілька деталей, які варто знати:

- **QuickBooks Online.** Intuit [прямо пише](https://developer.intuit.com/app/developer/qbo/docs/develop/authentication-and-authorization/faq), що другий запит з тим самим значенням отримає `invalid_grant` і може бути сприйнятий як проблема безпеки з відкликанням токенів, тому оновлювати треба по одному. Без серіалізації завантажена черга може відключити клієнтові бухгалтерію.
- **Xero** на кожне оновлення видає новий refresh-токен, але попередній ще [30 хвилин](https://developer.xero.com/documentation/guides/oauth2/token-types) лишається придатним. У контрольному запуску без блокування всі шість процесів отримали успіх і шість *різних* нових refresh-токенів. Помилки немає, тож інтеграція чудово проходить тести, але застосунок зберігає той токен, який записали останнім, а доля решти після 30 хвилин не задокументована. Не плутайте дві «30 хвилин»: стільки живе токен доступу, і стільки ж після ротації діє попередній refresh-токен. Сам refresh-токен живе до 60 днів.
- **Salesforce** - найсуворіший. З увімкненою [ротацією](https://help.salesforce.com/s/articleView?id=005316711&type=1) повторне використання вже недійсного refresh-токена відкликає і чинний токен разом з токенами доступу. Для нових External Client Apps ротація з 22 жовтня 2026 року увімкнена за замовчуванням і не вимикається. У тестах автора одночасні запити ще проходили (один з шести отримав `concurrent_request`), але повторне використання старого токена через 90 секунд відкликало все підключення.
- **Microsoft** на кожне оновлення видає новий refresh-токен, але [не відкликає](https://learn.microsoft.com/en-us/entra/identity-platform/refresh-tokens) старий. **Google** взагалі [не видає](https://developers.google.com/identity/protocols/oauth2/web-server) новий refresh-токен при оновленні. Тут серіалізація економить виклики, а не підключення.

### Чи завжди invalid_grant означає гонку

Ні. OAuth 2.0 використовує `invalid_grant` для будь-якого refresh-токена, що недійсний, прострочений, відкликаний або виданий іншому клієнту ([RFC 6749, розділ 5.2](https://datatracker.ietf.org/doc/html/rfc6749#section-5.2)). Перш ніж звинувачувати конкурентність, виключіть інші причини:

- **Новий refresh-токен не зберегли.** QuickBooks Online і Salesforce списують попереднє значення, Xero терпить його лише 30 хвилин.
- **Токеном давно не користувалися.** QuickBooks Online - 100 днів, Xero - до 60 днів, Salesforce - 30 днів простою для нових External Client Apps, Microsoft - `AADSTS70008` чи `AADSTS700082` після неактивності, Google - пів року без використання або 7 днів для застосунків у статусі «Testing» із зовнішніми користувачами.
- **Доступ відкликали.** Користувач відключив застосунок, або (Microsoft, `AADSTS50173`) дозвіл анулювали, наприклад після зміни пароля.
- **Забагато токенів.** Google анулює найстаріший, коли в одного акаунта понад 100 refresh-токенів для того самого клієнта.
- **Не той клієнт чи середовище.** Refresh-токен, отриманий з ключами одного застосунку, надіслали з ключами іншого, наприклад ключі пісочниці проти продакшену.
- **Політики Salesforce.** Політика застосунку може робити refresh-токени недійсними одразу, через фіксований час або через простій, а серед причин `invalid_grant` Salesforce називає ще й обмеження за IP чи годинами входу.

### Гонка чи справжнє відкликання: читаємо логи

Сама помилка цього не скаже. Скажуть такі сигнали:

- **Два виклики оновлення для того самого підключення з різницею в секунди.** Логуйте ключ підключення й час кожного виклику. Гонка залишає два чи більше близьких виклики, справжнє закінчення строку чи відкликання - один.
- **Надісланий refresh-токен уже було ротовано.** Логуйте короткий хеш надісланого й отриманого refresh-токена (ніколи не сам токен). Якщо виклик, що впав, надіслав значення, яке попередній виклик уже ротував, це гонка.
- **Підказки самого провайдера.** Salesforce відповідає на один з двох одночасних запитів `concurrent_request`. Microsoft кладе код `AADSTS` в `error_description`, і за ним видно різницю між неактивністю (`AADSTS70008`) та відкликаним дозволом (`AADSTS50173`).

### Рішення: Cache::lock на кожне підключення

Мінімальне виправлення - м'ютекс на кожне підключення: розподілене блокування (Redis чи база даних) навколо оновлення, щоб на token endpoint ходив лише один воркер, а решта чекали і брали його результат. Цей патерн ще називають single-flight. Ось ескіз автора з `Cache::lock` і вашою власною моделлю токена (тут через `league/oauth2-client`):

```php
$token = Cache::lock("oauth-refresh:{$key}", 30)->block(10, function () use ($key, $provider) {
  $token = Token::where('key', $key)->firstOrFail();          // читаємо ще раз усередині блокування
  if ($token->expires_at?->isAfter(now()->addMinutes(2))) {   // expires_at: nullable-каст 'datetime'
    return $token;                                           // інший воркер уже оновив токен
  }
  $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;
});
```

Ключове тут - повторне читання токена **всередині** блокування. Воркери, що чекали, майже завжди знаходять токен, який уже зберіг перший, і повертають його, не звертаючись до провайдера. Колонкам з токенами дайте каст `encrypted`.

Цей ескіз прибирає більшість дублів, але сам по собі не закриває всі дірки:

- блокування має обмежений строк дії (lease), тож оновлення, довше за 30 секунд, впустить другого воркера;
- запис, зроблений на основі застарілого читання, може перезаписати новіший токен;
- мережевий збій і справжній `invalid_grant` тут виглядають однаково, а збій не має позначати токен як відкликаний: таймаути, HTTP 408, 429 і 5xx - це тимчасова помилка, токен лишається;
- без `expires_in` (Salesforce) поле `expires_at` порожнє, і ескіз оновлюватиме токен на кожному виклику.

Тому до блокування додається **запис з порівнянням версії (compare-and-swap)**: у таблиці токенів є номер версії, і новий токен записується лише тоді, коли версія не змінилася з моменту читання. Також варто мати швидкий шлях: якщо токен доступу дійсний довше за запас (скажімо, дві хвилини), повертайте його без жодного блокування.

### Який механізм блокування обрати в Laravel

| Варіант | Між серверами | Завдання, веб-запити й команди | Повторне читання токена |
|---|---|---|---|
| `Cache::lock` на Redis, Memcached, DynamoDB чи базі даних | так, зі спільним сховищем | так | якщо читаєте ще раз усередині блокування |
| `lockForUpdate()` у транзакції (SQLite його ігнорує) | так | так | так, але транзакція висить відкритою весь HTTP-виклик до провайдера |
| `middleware` завдань `WithoutOverlapping` | так, зі спільним сховищем | лише завдання в черзі | ні |
| блокування в кеші `file` | ні, лише один сервер | так | якщо читаєте ще раз усередині блокування |
| блокування в кеші `array` | ні, лише один процес | - | - |

`WithoutOverlapping` не замінює блокування: воно стосується лише завдань у черзі з тим самим ключем і ніколи не перечитує токен.

### Навіщо і блокування, і умовний запис

Популярна порада - замість розподіленого блокування обійтися умовним записом («оновити, лише якщо версія не змінилася»). Він справді захищає сховище, але обидва воркери все одно звертаються до провайдера, і той бачить другий виклик. У тестах автора без блокування Xero видав шість «братніх» refresh-токенів, а Salesforce відповів `concurrent_request`, а потім при пізньому повторі відкликав усе підключення.

Блокування саме по собі має протилежну ваду: це оренда, і вона може закінчитися посеред оновлення. З орендою на 30 секунд:

```text
0 с    A  бере блокування, надсилає RT1 (повільно)
30 с      оренда спливає; запит A ще в дорозі
31 с   B  бере блокування, перечитує: досі RT1, надсилає RT1   ← провайдер бачить RT1 двічі
32 с   B  отримує invalid_grant, записує відкликання цієї версії
35 с   A  отримує RT2; compare-and-swap не проходить, A бачить, що B відкликав ротовану ним версію, і зберігає RT2
```

Це лише один можливий порядок - для провайдерів, де B отримує `invalid_grant` (QuickBooks, Salesforce). Тож потрібні обидва механізми: блокування не дає зробити другий виклик, а номер версії працює як fencing token і тримає сховище узгодженим тоді, коли блокування не впоралося. Чи працюватимуть нові токени A, залежить від провайдера: Salesforce після пізнього повтору може вже відкликати все. З Xero (у межах 30 хвилин) чи Microsoft оновлення B зазвичай просто проходить, compare-and-swap у A не спрацьовує, сховище тримає токени B, а в провайдера лишаються два покоління токенів.

Звідси практичне правило: **таймаут HTTP-запиту на оновлення має бути помітно меншим за строк оренди блокування.** Тоді повільний запит обірветься раніше, ніж блокування перейде до наступного воркера.

### Зберігайте кожен новий refresh-токен однією операцією

Токен доступу, refresh-токен і строк дії з кожної відповіді зберігайте разом, одним записом. Intuit прямо вимагає зберігати останнє значення refresh-токена з кожної відповіді. Якщо спершу записати токен доступу, а refresh-токен другим запитом, збій між ними залишить вам уже списаний refresh-токен.

Якщо у відповіді немає `refresh_token`, як у Google, лишайте той, що маєте.

### Що робити після invalid_grant

1. **Перечитайте збережений токен один раз.** Можливо, поки ваш запит був у дорозі, інший воркер уже оновив токен, і ваш refresh-токен просто застарів. Якщо версія у сховищі змінилася, беріть те, що зберіг інший воркер, а не позначайте токен відкликаним.
2. **Припиніть повтори.** Повторна відправка мертвого refresh-токена не допоможе, а з reuse detection може нашкодити ще більше. Позначте токен відкликаним, і наступні виклики хай одразу падають без звернення до провайдера.
3. **Попросіть користувача перепідключитися** - через сповіщення або позначку на інтеграції.

### Salesforce: оновлення за відповіддю 401

У відповіді Salesforce на оновлення немає `expires_in`: таймаут сесії налаштовується в самому Salesforce, тож застосунок дізнається про смерть токена доступу лише тоді, коли API відповідає 401 з `errorCode` `INVALID_SESSION_ID`. Логіка «оновлювати незадовго до закінчення строку» тут не працює - строку просто немає. Запланована команда, що оновлює токени заздалегідь, теж не має, на що орієнтуватися.

Тому для Salesforce оновлення має запускатися відповіддю 401: «отримали 401 - оновили - повторили запит». Щоб уникнути гонки, робіть це під тим самим блокуванням і з тим самим повторним читанням: воркер, що отримав 401, повідомляє, *який саме* токен доступу відхилено, і оновлення відбувається лише тоді, коли у сховищі досі лежить саме цей токен. Перший воркер оновлює токен, а решта, отримавши такі самі 401, знаходять у сховищі вже інший токен доступу і просто беруть його. Відповідь `concurrent_request` Salesforce [сам радить](https://help.salesforce.com/s/articleView?id=xcloud.remoteaccess_oauth_refresh_token_flow.htm&language=en_US&type=5) повторювати, тож вважайте її тимчасовою помилкою.

Загальне правило для всіх провайдерів: оновлюйте незадовго до закінчення строку, коли провайдер його повідомляє, і за відповіддю 401, коли не повідомляє або коли токен помер раніше. Запланована команда, що оновлює токени наперед, - це просто ще один воркер, і їй теж потрібне блокування. Розкид у часі (jitter) розподіляє навантаження між підключеннями, але ні jitter, ні двохвилинний запас не заважають двом воркерам змагатися за одне підключення.

### Обмеження підходу

- Блокування координує один застосунок зі спільним сховищем кешу. Воркери Horizon на кількох серверах узгоджуються, поки ділять одне сховище (наприклад, Redis). Два різні застосунки з тим самим OAuth-підключенням так не координуються.
- Автор перевіряв рішення конкурентним тестом: вісім PHP-процесів одночасно просили той самий токен, а контрольний запуск без блокування мав показати більше одного оновлення.

### Пакет автора

Усе описане вище автор зібрав у пакет [laravel-oauth-refresh-lock](https://github.com/dakiquang/laravel-oauth-refresh-lock): швидкий шлях, `Cache::lock` на кожен ключ, повторне читання, compare-and-swap за версією, розділення тимчасових помилок і `invalid_grant`, оновлення за 401 для Salesforce і події, що не містять значень токенів. У репозиторії є приклади для QuickBooks Online, Xero, Salesforce, Microsoft Graph і Google - їх корисно переглянути, навіть якщо ви пишете власну реалізацію.
