Увійти Реєстрація
Вакансії
Вакансії Laravel Компанії Статистика
Підготовка
Як підготуватися < href="https://laravelukraine.com/interviews" wire:navigate.hover class="flex items-center gap-3 rounded-md px-3 py-2 text-base font-medium text-gray-600 hover:bg-gray-50 hover:text-gray-900 dark:text-gray-400 dark:hover:bg-gray-900 dark:hover:text-white" >Співбесіди Тестування Документація
Блог
Всі статті Серії
Екосистема
Пакети Ресурси Проєкти Інструменти Події Відео
Інше
Про нас Реклама
Переклади 13 листопада 2026

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

Це переказ статті 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

  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 сам радить повторювати, тож вважайте її тимчасовою помилкою.

Загальне правило для всіх провайдерів: оновлюйте незадовго до закінчення строку, коли провайдер його повідомляє, і за відповіддю 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 - їх корисно переглянути, навіть якщо ви пишете власну реалізацію.

Переклад оригіналу: dev.to
13

Читати в документації

Коментарі

Увійдіть, щоб залишити коментар

Будьте першим, хто залишить коментар!

Читайте також

Переклади 14 листопада 2026

Голод сильніший за стаж: поради junior-інженерам від менеджера Anthropic

Фелікс Різеберг, engineering-менеджер в Anthropic, відмовився від технічних співбесід з кодуванням і пояснює, чому в епоху AI новачки обганяють досвідчених колег - і що конкретно їм робити, щоб їх помітили.

14
Переклади 13 листопада 2026

Налаштування Laravel Horizon: що перевірити, коли черги перевантажують сервер

Розсилки Mailcoach перевантажували сервер, і Стан Даніелс переглянув налаштування Horizon: хто споживає черги, timeout і retry_after, перезапуск після розгортання, алерти очікування і кількість воркерів.

10

Вакансії за темою

ВебФорза Нова
Сьогодні

Програміст PHP (Laravel, WordPress, гіг-контракт)

Розробка на фреймворку Laravel та створення тем і плагінів для WordPress. Завдання включає роботу з jQuery, JS, CSS, HTML і PHP, використання git та оптимізацію швидкості завантаження сторінок. Оформлення за гіг-контрактом з погодинною оплатою.

Senior Full-Stack Developer (PHP / Go / Vue.js)

Senior Full-Stack розробник матиме розвивати й підтримувати існуючі веб-платформи, писати модулі, сервіси та API, а також розробляти клієнтську частину на Vue.js і Vanilla JS. Основний стек: PHP 8.x з Yii2 та/або Laravel, Go для сервісів, MySQL/MariaDB, REST API і Webhooks. Вимоги: досвід від 3 років, знання ООП і шаблонів проєктування, ES6+, Vue.js, SPA, Git, Docker і Composer; англійська B1-B2.

Full Stack Developer (PHP / React) Middle / Middle+

Full Stack розробник розвиватиме та підтримуватиме власний аналітичний продукт для перевірки контрагентів і оцінки ризиків, працюючи зі складною бізнес-логікою та великими обсягами даних. Основний стек: PHP 8.x, Laravel або Symfony, MySQL, React; також потрібні проєктування API, оптимізація SQL-запитів, Docker, CI/CD. Вимоги: комерційний досвід від 3 років, досвід підтримки legacy-коду, англійська B1+ для читання документації.

Пакети за темою

Kalimera

welldigit/kalimera

Interactive installer that scaffolds Laravel applications the Well Digit way.

v1.0.0 17

Bagisto

bagisto/bagisto

Bagisto — це платформа для електронної комерції, побудована на Laravel. Вона надає готове рішення для створення та управління інтернет-магазинами з підтримкою каталогу товарів, замовлень, платежів та клієнтів.

28,369 v2.5.1 13 28