Це переказ статті Laravel OAuth refresh token race condition Куанга Данга (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 (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 прямо пише, що другий запит з тим самим значенням отримає
invalid_grant і може бути сприйнятий як проблема безпеки з відкликанням токенів, тому оновлювати треба по одному. Без серіалізації завантажена черга може відключити клієнтові бухгалтерію.
- Xero на кожне оновлення видає новий refresh-токен, але попередній ще 30 хвилин лишається придатним. У контрольному запуску без блокування всі шість процесів отримали успіх і шість різних нових refresh-токенів. Помилки немає, тож інтеграція чудово проходить тести, але застосунок зберігає той токен, який записали останнім, а доля решти після 30 хвилин не задокументована. Не плутайте дві «30 хвилин»: стільки живе токен доступу, і стільки ж після ротації діє попередній refresh-токен. Сам refresh-токен живе до 60 днів.
- Salesforce - найсуворіший. З увімкненою ротацією повторне використання вже недійсного refresh-токена відкликає і чинний токен разом з токенами доступу. Для нових External Client Apps ротація з 22 жовтня 2026 року увімкнена за замовчуванням і не вимикається. У тестах автора одночасні запити ще проходили (один з шести отримав
concurrent_request), але повторне використання старого токена через 90 секунд відкликало все підключення.
- Microsoft на кожне оновлення видає новий refresh-токен, але не відкликає старий. Google взагалі не видає новий refresh-токен при оновленні. Тут серіалізація економить виклики, а не підключення.
Чи завжди invalid_grant означає гонку
Ні. OAuth 2.0 використовує invalid_grant для будь-якого refresh-токена, що недійсний, прострочений, відкликаний або виданий іншому клієнту (RFC 6749, розділ 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):
$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 секунд:
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
- Перечитайте збережений токен один раз. Можливо, поки ваш запит був у дорозі, інший воркер уже оновив токен, і ваш refresh-токен просто застарів. Якщо версія у сховищі змінилася, беріть те, що зберіг інший воркер, а не позначайте токен відкликаним.
- Припиніть повтори. Повторна відправка мертвого refresh-токена не допоможе, а з reuse detection може нашкодити ще більше. Позначте токен відкликаним, і наступні виклики хай одразу падають без звернення до провайдера.
- Попросіть користувача перепідключитися - через сповіщення або позначку на інтеграції.
Salesforce: оновлення за відповіддю 401
У відповіді Salesforce на оновлення немає expires_in: таймаут сесії налаштовується в самому Salesforce, тож застосунок дізнається про смерть токена доступу лише тоді, коли API відповідає 401 з errorCode INVALID_SESSION_ID. Логіка «оновлювати незадовго до закінчення строку» тут не працює - строку просто немає. Запланована команда, що оновлює токени заздалегідь, теж не має, на що орієнтуватися.
Тому для Salesforce оновлення має запускатися відповіддю 401: «отримали 401 - оновили - повторили запит». Щоб уникнути гонки, робіть це під тим самим блокуванням і з тим самим повторним читанням: воркер, що отримав 401, повідомляє, який саме токен доступу відхилено, і оновлення відбувається лише тоді, коли у сховищі досі лежить саме цей токен. Перший воркер оновлює токен, а решта, отримавши такі самі 401, знаходять у сховищі вже інший токен доступу і просто беруть його. Відповідь concurrent_request Salesforce сам радить повторювати, тож вважайте її тимчасовою помилкою.
Загальне правило для всіх провайдерів: оновлюйте незадовго до закінчення строку, коли провайдер його повідомляє, і за відповіддю 401, коли не повідомляє або коли токен помер раніше. Запланована команда, що оновлює токени наперед, - це просто ще один воркер, і їй теж потрібне блокування. Розкид у часі (jitter) розподіляє навантаження між підключеннями, але ні jitter, ні двохвилинний запас не заважають двом воркерам змагатися за одне підключення.
Обмеження підходу
- Блокування координує один застосунок зі спільним сховищем кешу. Воркери Horizon на кількох серверах узгоджуються, поки ділять одне сховище (наприклад, Redis). Два різні застосунки з тим самим OAuth-підключенням так не координуються.
- Автор перевіряв рішення конкурентним тестом: вісім PHP-процесів одночасно просили той самий токен, а контрольний запуск без блокування мав показати більше одного оновлення.
Пакет автора
Усе описане вище автор зібрав у пакет laravel-oauth-refresh-lock: швидкий шлях, Cache::lock на кожен ключ, повторне читання, compare-and-swap за версією, розділення тимчасових помилок і invalid_grant, оновлення за 401 для Salesforce і події, що не містять значень токенів. У репозиторії є приклади для QuickBooks Online, Xero, Salesforce, Microsoft Graph і Google - їх корисно переглянути, навіть якщо ви пишете власну реалізацію.