Питання на співбесіді: Захист і інтеграції
Питання з реальних співбесід з відповідями: Laravel і PHP, бази даних, JavaScript і фронтенд, Git, Docker, API, безпека й архітектура. Тими самими темами, що й тести.
15 питань
- Автентифікація відповідає на питання «хто ви?»: перевіряє токен, ключ, пароль.
- Авторизація - «що вам можна?»: чи має цей користувач право на цю дію з цим ресурсом.
Спершу автентифікація, потім авторизація. Помилки - різні: 401 - не вдалося встановити, хто ви; 403 - відомо, хто ви, але дія заборонена.
Способи автентифікації в API:
- API-ключ у заголовку (
X-API-KeyчиAuthorization: Bearer ...) - для серверних інтеграцій. - Токени користувачів - персональні токени доступу (Laravel Sanctum) чи токени OAuth 2.0.
- Cookie-сесія - для власного SPA на тому самому домені (Sanctum SPA-режим), з CSRF-захистом.
- JWT - самодостатній підписаний токен; сервер перевіряє підпис без запиту до бази, але відкликати такий токен до закінчення терміну складніше.
Авторизація - на кожному ендпоінті, на сервері:
public function update(UpdateOrderRequest $request, Order $order)
{
$this->authorize('update', $order); // чи це замовлення цього користувача
// ...
}
Найпоширеніша вразливість API (OWASP API Top 10, BOLA) - є автентифікація, але немає перевірки прав на конкретний об'єкт: GET /orders/43 повертає чуже замовлення, бо код перевірив лише, що користувач залогінений.
Ще правила: токени лише по HTTPS, ніколи в URL (потраплять у логи й історію браузера), з обмеженим терміном дії й мінімально потрібними правами (scopes).
Браузер дотримується same-origin policy: JavaScript зі сторінки https://shop.example не може прочитати відповідь з https://api.other.example. CORS (Cross-Origin Resource Sharing) - механізм, яким сервер дозволяє браузеру послабити це обмеження для певних джерел.
Access-Control-Allow-Origin: https://shop.example
Access-Control-Allow-Methods: GET, POST, PATCH
Access-Control-Allow-Headers: Authorization, Content-Type
Access-Control-Allow-Credentials: true
Для «непростих» запитів (з Authorization, JSON-тілом, методами PUT/DELETE) браузер спершу надсилає preflight - OPTIONS-запит з питанням, чи можна. Access-Control-Max-Age дозволяє кешувати відповідь preflight.
Чому CORS - не захист API:
- Його виконує браузер.
curl, Postman, скрипт на сервері, мобільний застосунок CORS не перевіряють і отримують відповідь незалежно від заголовків. - CORS захищає користувача в браузері від того, щоб чужий сайт від його імені читав дані з вашого API. Але не захищає сам API від прямих запитів.
- Навіть із забороненим CORS простий запит (
POSTз формою) доходить до сервера й виконується - браузер лише не віддає скрипту відповідь. Тому від CSRF рятують токени й cookieSameSite, а не CORS.
Захист API - це автентифікація, авторизація, ліміти запитів, валідація.
Типові помилки налаштування:
Access-Control-Allow-Origin: *разом з credentials - браузер це не дозволяє.- Віддзеркалення будь-якого
Originз запиту у відповідь разом зAllow-Credentials: true- фактично дозволяє будь-якому сайту читати дані користувача.
Усі три способи відповідають на питання «хто робить запит», але для різних клієнтів.
Сесійна cookie - браузерна автентифікація:
- користувач входить, сервер створює сесію і ставить cookie (
HttpOnly,Secure,SameSite); - браузер сам додає cookie до кожного запиту;
- для кого: власний фронтенд на тому ж домені (Blade, Livewire, Inertia, SPA через Sanctum);
- переваги: токен недоступний JavaScript (захист від крадіжки через XSS), вихід - знищити сесію на сервері;
- потребує захисту від CSRF - бо браузер надсилає cookie автоматично.
Токен доступу (bearer token) - автентифікація від імені користувача для небраузерних клієнтів:
Authorization: Bearer 1|AbCdEf...
- для кого: мобільні застосунки, десктоп, CLI, сторонні клієнти від імені користувача (OAuth);
- токени можна обмежити правами (scopes/abilities) і терміном дії, відкликати окремо для кожного пристрою;
- CSRF не загрожує - браузер сам токен не додає.
API-ключ - ідентифікація застосунку-інтегратора, а не людини:
- для кого: сервер-сервер інтеграції (партнер вивантажує замовлення, CRM синхронізує контакти);
- зазвичай довгоживучий, прив'язаний до облікового запису клієнта, з власними лімітами й правами;
- передається в заголовку (
AuthorizationчиX-Api-Key), ніколи в URL.
Типові помилки:
- токени в
localStorageдля власного SPA - будь-який XSS їх вкраде. Для SPA на своєму домені cookie-сесія безпечніша; - API-ключ у мобільному застосунку чи фронтенді - будь-хто дістане його з бандлу. Ключі - лише на серверах;
- токени без терміну дії і без можливості відкликання;
- ключі в Git, логах, URL - сканери публічних репозиторіїв знаходять їх за хвилини.
Як зберігати на сервері: як паролі - лише хеш (порівняння з хешем отриманого значення). Тоді витік бази не дає готових ключів. Так зберігає токени Laravel Sanctum (SHA-256).
Найпоширеніша вразливість API за OWASP - не спосіб автентифікації, а відсутність перевірки доступу до конкретного об'єкта після автентифікації.
Через HTTP без шифрування кожен запит видно будь-кому на шляху: публічний Wi-Fi, провайдер, скомпрометований роутер. Токени в Authorization, cookie сесій, паролі в тілі запиту, персональні дані у відповідях - усе в відкритому вигляді. Крім читання, трафік можна змінити (вставити скрипт, підмінити відповідь).
HTTPS (TLS) дає:
- шифрування - вміст недоступний стороннім;
- цілісність - зміна трафіку буде виявлена;
- автентичність сервера - сертифікат підтверджує, що клієнт говорить саме з вашим доменом.
Правила для API:
- лише HTTPS, без «а на HTTP теж працює». Запит на HTTP краще відхиляти, а не перенаправляти: редирект
301для API означає, що токен уже пройшов відкритим каналом у першому запиті; - сертифікати - автоматичне оновлення (Let's Encrypt, Caddy, хмарні балансувальники), моніторинг терміну дії;
- сучасний TLS (1.2+, краще 1.3), без застарілих шифрів.
HSTS (Strict-Transport-Security) - заголовок, що каже браузеру: «з цим доменом - лише HTTPS, протягом указаного часу»:
Strict-Transport-Security: max-age=31536000; includeSubDomains
Після першого візиту браузер сам переписуватиме http:// на https:// ще до відправки запиту - атака з «пониженням» до HTTP (SSL stripping) не спрацює. preload і включення домену в список браузерів захищає навіть перший візит.
Що варто знати:
- HSTS стосується браузерів. Мобільні застосунки й серверні клієнти його не читають - їх треба конфігурувати на HTTPS-адреси;
includeSubDomains- лише якщо всі піддомени готові до HTTPS, інакше вони стануть недоступними на весьmax-age;- за проксі (Cloudflare, балансувальник) TLS може закінчуватися на проксі. Laravel має довіряти заголовкам
X-Forwarded-Proto(trustProxies), інакше генеруватимеhttp://посилання й вважатиме запити незахищеними; - cookie з
Secureне передаються по HTTP взагалі.
Між внутрішніми сервісами теж варто шифрувати трафік: «внутрішня мережа» часто не така закрита, як здається, а для критичних з'єднань - взаємна автентифікація (mTLS).
Масове призначення (mass assignment) - коли API бере тіло запиту й записує його в модель цілком. Клієнт додає поле, якого форма не має, - і змінює те, що не повинен.
// небезпечно
$user->update($request->all());
PATCH /api/profile
{"name": "Оля", "is_admin": true, "balance": 1000000}
Дзеркальна проблема - зайві дані у відповіді: return $user; віддає всі атрибути моделі, включно з тими, що клієнту бачити не можна (внутрішні прапорці, хеші, токени, приховані поля інших користувачів). OWASP об'єднує обидві проблеми в категорію «порушена авторизація на рівні властивостей об'єкта».
Захист на вході:
// лише перевірені поля
$validated = $request->validate([
'name' => ['required', 'string', 'max:255'],
'bio' => ['nullable', 'string', 'max:1000'],
]);
$user->update($validated);
validate()/ Form Request повертає лише ті поля, для яких є правила, - решта ігнорується;$fillableу моделі - друга лінія захисту: навітьupdate($request->all())не запише поля поза списком.$guarded = []вимикає цей захист;- поля, залежні від прав (роль, статус модерації), - окремі ендпойнти чи явні перевірки: «змінювати
roleможе лише адміністратор».
Захист на виході:
return new UserResource($user); // явний перелік полів
- API Resource чи DTO з явним переліком полів замість серіалізації моделі;
$hiddenу моделі (паролі, токени) - корисно, але недостатньо: краще відповідь описувати явно;- поля за правами:
'email' => $this->when($request->user()->can('viewContacts', $this->resource), $this->email).
Ознаки проблеми під час рев'ю коду:
$request->all()чи$request->input()без валідації, передане вcreate/update;return $modelабо$model->toArray()у контролерах API;$guarded = []у моделях, що змінюються через API.
Тест, що варто мати: відправити зайве поле (is_admin: true) і перевірити, що воно не змінилося.
Докладніше в документації: OWASP API3:2023 - авторизація на рівні властивостей
OAuth 2.0 дозволяє застосунку отримати доступ до даних користувача в іншому сервісі (Google, GitHub) без його пароля: користувач підтверджує доступ на сторінці сервісу, а застосунок отримує токен з обмеженими правами.
Authorization Code flow:
- Застосунок перенаправляє користувача на сервер авторизації з
client_id,redirect_uri,scopeі випадковимstate. - Користувач входить і погоджується надати доступ.
- Сервер повертає користувача на
redirect_uriз одноразовим кодом і тим самимstate. - Застосунок перевіряє
state(захист від CSRF) і обмінює код на токени прямим запитом сервер-сервер. - Отримує
access_token(короткоживучий) і частоrefresh_token.
PKCE (Proof Key for Code Exchange) захищає від перехоплення коду. Перед кроком 1 застосунок генерує випадковий code_verifier і передає його хеш - code_challenge. На кроці 4 надсилає сам code_verifier. Сервер перевіряє, що хеш збігається, - тож перехоплений код без verifier'а марний.
code_verifier = випадковий рядок 43-128 символів
code_challenge = BASE64URL(SHA256(code_verifier)), method = S256
Чому PKCE тепер обов'язковий скрізь: спершу його придумали для мобільних і SPA-застосунків, які не можуть зберігати client_secret. Сучасні рекомендації (OAuth 2.0 Security BCP, OAuth 2.1) вимагають PKCE і для серверних клієнтів.
Чого не використовувати: Implicit flow (токен одразу в URL) і Resource Owner Password flow (застосунок бере пароль користувача) - обидва вважаються застарілими й небезпечними.
У Laravel: вхід через сторонні сервіси - Socialite (він уже передає state і підтримує PKCE), власний OAuth-сервер - Passport.
Rate limiting обмежує, скільки запитів клієнт може зробити за проміжок часу. Захищає від перевантаження, перебору паролів і токенів, масового викачування даних і від одного «галасливого» клієнта, що забирає ресурси в інших.
Чим рахувати:
- за користувачем чи токеном - для автентифікованих запитів, найточніше;
- за IP - для анонімних (вхід, реєстрація, скидання пароля), з поправкою на NAT і проксі;
- за ендпоінтом: вхід - 5 спроб на хвилину, пошук - 60, звичайні запити - більше.
Алгоритми: фіксоване вікно (просто, але дозволяє «сплеск» на межі вікон), ковзне вікно, token bucket (дозволяє короткі сплески до місткості «відра» за стабільної середньої швидкості).
Відповідь при перевищенні - 429 Too Many Requests з підказками клієнту:
HTTP/1.1 429 Too Many Requests
Retry-After: 30
X-RateLimit-Limit: 60
X-RateLimit-Remaining: 0
Retry-After каже, через скільки секунд повторити. X-RateLimit-* - поширена (хоч і нестандартна) практика; IETF працює над стандартними заголовками RateLimit і RateLimit-Policy.
У Laravel:
RateLimiter::for('api', fn (Request $request) =>
Limit::perMinute(60)->by($request->user()?->id ?: $request->ip())
);
Лічильники мають жити в спільному сховищі (Redis), інакше на кількох серверах кожен рахуватиме своє.
Клієнтам - обробляти 429 з експоненційною затримкою й випадковим розкидом (jitter), а не повторювати одразу. Шари захисту: ліміти на рівні CDN/WAF відсікають грубі атаки ще до застосунку, а застосунок обмежує за бізнес-логікою.
BOLA (Broken Object Level Authorization, раніше IDOR - Insecure Direct Object Reference) - API перевіряє, що користувач автентифікований, але не перевіряє, чи має він доступ саме до цього об'єкта.
// вразливо: будь-який автентифікований користувач отримає будь-яке замовлення
Route::get('/api/orders/{order}', fn (Order $order) => new OrderResource($order))
->middleware('auth:sanctum');
Зловмисник змінює /api/orders/1041 на /api/orders/1040 - і бачить чуже замовлення. Ідентифікатори в API на виду, перебрати їх - справа скрипта.
Чому це вразливість №1 у OWASP API Top 10:
- у кожному ендпойнті з ідентифікатором треба окремо не забути перевірку;
- автоматичні сканери її погано знаходять - технічно запит коректний;
- тести зазвичай перевіряють «власник бачить своє», а не «чужий не бачить».
Захист у Laravel:
1. Політики:
public function show(Order $order): OrderResource
{
$this->authorize('view', $order); // OrderPolicy::view
return new OrderResource($order);
}
// або на маршруті
->can('view', 'order');
2. Пошук через власника - чужий запис просто не знайдеться:
$order = $request->user()->orders()->findOrFail($id);
3. Вкладені маршрути з обмеженням: scopeBindings() - дочірній запис має належати батьківському.
4. Списки - теж об'єкти: GET /api/orders?user_id=5 не повинен повертати замовлення іншого користувача. Фільтр за власником - на сервері, а не з параметра.
Чого не робити:
- покладатися на непередбачувані ID (UUID замість автоінкременту). UUID ускладнює перебір, але не замінює перевірку: ідентифікатор може потрапити в URL, лог, лист;
- перевіряти лише в інтерфейсі (прихована кнопка) - API викликають напряму;
- довіряти ідентифікатору з тіла запиту (
"owner_id": 5) - власника визначають з автентифікації.
Тест на кожен ендпойнт з ідентифікатором:
it('forbids viewing another user\'s order', function () {
$order = Order::factory()->create();
Sanctum::actingAs(User::factory()->create());
$this->getJson("/api/orders/{$order->id}")->assertForbidden();
});
Відповідь 403 чи 404: 404 не підтверджує, що об'єкт існує, - для чутливих даних це краще.
JWT (JSON Web Token) - токен з трьох частин у Base64URL, розділених крапками: заголовок.дані.підпис.
eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiI0MiIsImV4cCI6MTc2MDAwMDAwMH0.Sfl...
- заголовок - алгоритм підпису (
alg); - дані (claims) -
sub(користувач),exp(термін дії),iss,aud, ролі тощо; - підпис - гарантує, що дані не змінено.
Ключова властивість: сервер може перевірити токен без звернення до бази - достатньо ключа. Звідси популярність у мікросервісах.
Що варто розуміти:
- JWT не шифрований (якщо це не JWE) - будь-хто прочитає дані, декодувавши Base64. Не кладіть туди персональних даних і секретів;
- підпис ≠ секретність: підпис захищає від зміни, а не від читання.
Типові вразливості (RFC 8725 описує найкращі практики):
alg: none- бібліотека, що приймає непідписані токени. Сервер має жорстко задавати дозволені алгоритми, а не брати їх із заголовка токена;- плутанина алгоритмів: сервер очікує RS256 (асиметричний), а зловмисник підписує HS256, використавши публічний ключ як секрет. Захист - той самий: явний список алгоритмів;
- слабкий секрет HS256 - коротку фразу перебирають офлайн;
- відсутня перевірка
exp,aud,iss- токен з іншого сервісу чи прострочений приймається; - неможливість відкликання: stateless-токен діє до
exp, навіть якщо користувач вийшов чи його заблоковано. Ліки - короткий термін (5-15 хвилин) + refresh-токен, або чорний список (jti) - що повертає звернення до сховища; - зберігання в
localStorage- крадіжка через XSS.
Коли JWT не потрібен: для власного застосунку з одним бекендом - звичайна сесія чи непрозорий токен у базі (як Sanctum) простіші: відкликання миттєве, у токені немає даних, бібліотеки не потрібні.
Коли доречний: кілька сервісів перевіряють один токен без спільної бази; OAuth/OpenID Connect (ID-токени - це JWT); короткоживучі підписані посилання.
У Laravel: Sanctum використовує непрозорі токени з хешем у базі; Passport (OAuth2-сервер) видає JWT-токени доступу.
GET /api/export?api_token=sk_live_9f8a...
Токен у рядку запиту «протікає» в місця, які ніхто не захищає як сховище секретів:
- журнали доступу вебсервера, балансувальника, CDN, проксі - URL записується повністю;
- історія браузера й закладки;
- заголовок
Referer- при переході з такої сторінки на інший сайт URL може піти туди; - системи моніторингу й аналітики (Sentry, APM), що записують URL запитів;
- кеш проксі - відповідь може закешуватися за URL з токеном;
- знімки екрана й повідомлення в чатах підтримки.
RFC 6750 прямо не рекомендує передавати bearer-токени в параметрах URL, крім випадків, коли інших варіантів немає.
Правильно - заголовок:
Authorization: Bearer sk_live_9f8a...
Якщо URL без токена неможливий (завантаження файлу за посиланням, WebSocket у браузері, вбудовування зображення) - короткоживучий одноразовий токен чи підписаний URL з терміном дії, прив'язаний до конкретного ресурсу:
URL::temporarySignedRoute('exports.download', now()->plus(minutes: 10), ['export' => $export]);
Що ще не повинно потрапляти в логи API:
- заголовки
Authorization,Cookie,X-Api-Key; - паролі, коди підтвердження, одноразові коди 2FA (типово - поля
password,token,codeу тілі); - номери карток, персональні документи, медичні дані;
- повні тіла відповідей із персональними даними.
Як це забезпечити:
- маскування в логах централізовано (процесор логів, фільтр полів у Sentry/Telescope), а не «не забути» в кожному місці;
- у Laravel -
$hiddenдля серіалізації моделей,#[\SensitiveParameter]для параметрів функцій (значення не з'явиться в стеку винятку), налаштуванняdontFlashдля полів, що не повертаються у форму після помилки; - токени з префіксом (
sk_live_...) - їх легше знайти й замаскувати сканерами секретів. Sanctum підтримує префікс черезtoken_prefix.
Якщо секрет усе ж потрапив у лог чи репозиторій - вважати його скомпрометованим і відкликати, а не лише видалити запис.
Ендпоінт вебхука публічний: будь-хто, хто знає адресу, може надіслати на нього «платіж підтверджено». Тому кожен запит треба перевіряти.
1. Підпис HMAC. Відправник обчислює HMAC від тіла запиту спільним секретом і передає в заголовку. Отримувач рахує те саме й порівнює:
$expected = 'sha256=' . hash_hmac('sha256', $request->getContent(), config('services.github.webhook_secret'));
abort_unless(hash_equals($expected, (string) $request->header('X-Hub-Signature-256')), 403);
- Підписується сире тіло запиту, байт у байт - не розібраний і знову серіалізований JSON.
- Порівняння - через
hash_equals, за сталий час.
2. Мітка часу проти повторів. Перехоплений правильний запит можна надіслати ще раз. Тому відправник включає в підпис мітку часу (як Stripe: t=...,v1=...), а отримувач відкидає запити, старші за кілька хвилин.
3. Ідемпотентна обробка. Відправники доставляють вебхуки «щонайменше раз» і повторюють їх при помилках чи таймаутах. Кожна подія має ID - зберігайте оброблені ID і пропускайте дублікати.
4. Швидка відповідь, обробка в черзі. Перевірити підпис, зберегти подію, поставити завдання в чергу й одразу відповісти 2xx. Довга обробка в самому запиті призводить до таймаутів і повторних доставок.
5. Не довіряти вмісту сліпо. Для критичних подій (оплата) безпечно перезапитати стан через API відправника: «чи справді платіж pi_123 успішний?».
Додатково: HTTPS обов'язковий; білий список IP відправника - лише як доповнення, не замість підпису; ротація секрету з періодом, коли приймаються обидва.
Докладніше в документації: Перевірка доставок вебхуків GitHub
Cache-Control каже клієнтам і проміжним кешам (CDN, проксі), чи можна зберігати відповідь і як довго:
Cache-Control: public, max-age=300 # будь-хто може кешувати 5 хвилин
Cache-Control: private, max-age=60 # лише браузер користувача
Cache-Control: no-store # не зберігати взагалі (персональні, чутливі дані)
Cache-Control: no-cache # зберігати, але перевіряти перед кожним використанням
Умовні запити й ETag. Сервер віддає «відбиток» версії ресурсу:
HTTP/1.1 200 OK
ETag: "a1b2c3"
Cache-Control: no-cache
Наступного разу клієнт питає «чи змінилося?»:
GET /products/42
If-None-Match: "a1b2c3"
Якщо ні - 304 Not Modified без тіла. Економиться трафік і серіалізація, хоча сам запит до сервера відбувається. Аналог за датою - Last-Modified / If-Modified-Since.
ETag для конкурентних змін: If-Match: "a1b2c3" при PUT/PATCH - оновити лише якщо ресурс не змінився з того часу, як клієнт його прочитав. Інакше 412 Precondition Failed. Це оптимістичне блокування на рівні HTTP, захист від загублених оновлень.
Нюанси:
- Персональні відповіді не можна позначати
public: CDN віддасть дані одного користувача іншому. Для відповідей, що залежать від токена, -privateабоno-store. Varyкаже кешу, від яких заголовків запиту залежить відповідь:Vary: Accept-Language,Vary: Authorization.- ETag має бути дешевим: якщо для нього треба зібрати всю відповідь, економиться лише трафік. Краще брати версію з бази (
updated_at, лічильник версії). - Інвалідація - найскладніше: короткий
max-ageплюсstale-while-revalidateчасто практичніші за спроби точно скидати кеш.
SSRF (Server-Side Request Forgery) - зловмисник змушує ваш сервер зробити запит туди, куди йому потрібно. Типові функції-мішені: імпорт за URL, попередній перегляд посилань, вебхуки (користувач вказує URL для сповіщень), завантаження аватара за посиланням, генерація PDF з HTML.
// вразливо
$image = Http::get($request->input('avatar_url'))->body();
Що можна дістати через ваш сервер:
- сервіси метаданих хмари:
http://169.254.169.254/- тимчасові облікові дані AWS/GCP/Azure. Класичний сценарій масштабних витоків; - внутрішні сервіси, недоступні ззовні: адмінки, бази з HTTP-інтерфейсом, Redis, панелі моніторингу,
localhost:8080; - сканування внутрішньої мережі за часом відповіді;
- файли через схеми
file://,gopher://- залежно від HTTP-клієнта.
Захист (кілька шарів, бо кожен окремо обходиться):
1. Дозволений список, а не заборонений. Якщо можна - лише відомі домени (інтеграції з конкретними сервісами).
2. Схема й порт: лише https (за потреби http), стандартні порти.
3. Перевірка IP після розв'язання DNS: заборонити приватні діапазони (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16), 127.0.0.0/8, link-local 169.254.0.0/16, IPv6-аналоги (::1, fc00::/7, fe80::/10).
Пастки, через які наївна перевірка не працює:
- DNS rebinding: домен при перевірці дає публічну адресу, а при реальному запиті - внутрішню. Перевіряти треба той IP, до якого реально підключаєтесь (зафіксувати розв'язану адресу для з'єднання);
- редиректи: перевірений URL повертає
302наhttp://169.254.169.254/. Редиректи вимкнути або перевіряти кожен крок; - альтернативні записи IP:
http://2130706433/,http://0x7f.1/,http://[::ffff:127.0.0.1]/- усе це127.0.0.1.
4. Мережева ізоляція: вихідні запити до користувацьких URL - через окремий проксі чи сервіс без доступу до внутрішньої мережі й метаданих. Найнадійніший шар.
5. Хмара: IMDSv2 на AWS (сесійний токен для метаданих) робить класичну атаку значно складнішою.
6. Обмеження відповіді: тайм-аути, максимальний розмір, перевірка типу вмісту - і не повертати клієнту сирі відповіді чи детальні помилки підключення (це дає зловмиснику «сліпе» сканування).
Для вебхуків з URL від користувача - ті самі правила плюс перевірка URL при збереженні й при кожній відправці.
Навіщо два різні токени:
- токен доступу - короткоживучий (хвилини), надсилається з кожним запитом. Якщо його вкрадуть - шкода обмежена часом;
- refresh-токен - довгоживучий (дні, тижні), використовується лише для отримання нового токена доступу і лише на ендпойнті авторизації.
Це компроміс між безпекою (короткі токени доступу) і зручністю (користувач не вводить пароль щогодини).
Чому refresh-токен - найцінніша мішень: хто його має, той може безстроково отримувати нові токени доступу. Тому RFC 9700 (найкращі практики безпеки OAuth 2.0) вимагає для публічних клієнтів (SPA, мобільні застосунки) або прив'язки токена до клієнта (DPoP, mTLS), або ротації.
Ротація refresh-токенів: кожне оновлення видає новий refresh-токен, а старий стає недійсним.
refresh_1 → (access_2, refresh_2) refresh_1 недійсний
refresh_2 → (access_3, refresh_3) refresh_2 недійсний
Виявлення повторного використання - головна перевага ротації:
- зловмисник викрав
refresh_2і скористався ним першим - отримавrefresh_3; - легітимний клієнт пізніше пред'являє
refresh_2- вже використаний; - сервер розуміє, що один з двох - зловмисник, і відкликає всю сім'ю токенів (усі, що походять від початкового входу). Обидва мусять увійти заново, а зловмисник втрачає доступ.
Для цього сервер зберігає ланцюжок: кожен refresh-токен знає свою «сім'ю» і чи був використаний.
Практичні деталі:
- паралельні запити: дві вкладки одночасно оновлюють токен з тим самим refresh-токеном - друга виглядає як «повторне використання». Рішення - короткий період допуску для того самого токена або серіалізація оновлення на клієнті;
- абсолютний термін життя сесії: навіть з ротацією ланцюжок не повинен жити вічно (наприклад, 30-90 днів до повторного входу);
- відкликання при зміні пароля і виході - усіх refresh-токенів користувача;
- зберігання: хеш у базі (як паролі); на клієнті - захищене сховище ОС для мобільних,
HttpOnly-cookie для вебу.
Для браузерних SPA взагалі варто зважити, чи потрібні токени: сесійна cookie з бекендом на тому ж домені (Sanctum SPA) чи патерн BFF (бекенд для фронтенду, що тримає токени на сервері) прибирають токени з браузера.
У Laravel Passport refresh-токени є з коробки; терміни задаються Passport::tokensExpireIn() і Passport::refreshTokensExpireIn().
Докладніше в документації: RFC 9700: найкращі практики безпеки OAuth 2.0
Коли ваш сервіс викликає інший (мікросервіс, партнерський API, внутрішній воркер), потрібно довести, який сервіс робить запит, і що запит не змінено по дорозі.
1. Статичний API-ключ / спільний секрет у заголовку - найпростіше:
Authorization: Bearer svc_billing_8f2a...
Мінуси: довгоживучий секрет, що зберігається в кількох місцях; викрадений ключ працює звідусіль; ротація болісна. Прийнятно для простих інтеграцій із ротацією й обмеженням за IP.
2. Підпис запиту (HMAC) - секрет не передається мережею, передається підпис:
X-Timestamp: 1760000000
X-Signature: hex(HMAC-SHA256(secret, method + path + timestamp + sha256(body)))
Отримувач обчислює підпис сам і порівнює у постійному часі (hash_equals). Мітка часу з коротким вікном (кілька хвилин) захищає від повторного відтворення. Так підписують вебхуки (Stripe, GitHub) і запити AWS (SigV4).
3. Взаємний TLS (mTLS) - обидві сторони пред'являють сертифікати:
- сервер перевіряє клієнтський сертифікат, виданий вашим внутрішнім центром сертифікації;
- ідентичність сервісу - у сертифікаті, а не в секреті в коді;
- RFC 8705 описує прив'язку OAuth-токенів до сертифіката: викрадений токен без приватного ключа клієнта непридатний.
Мінус - інфраструктура: власний CA, видача й ротація сертифікатів. Service mesh (Istio, Linkerd) роблять mTLS прозорим для застосунку.
4. Короткоживучі токени від центрального сервісу ідентифікації - OAuth 2.0 Client Credentials:
POST /oauth/token
grant_type=client_credentials&client_id=billing&client_secret=...&scope=orders:read
Сервіс отримує токен на хвилини з конкретними правами (scopes), отримувач перевіряє підпис і aud. У хмарах - ідентичності робочих навантажень (IAM-ролі, workload identity) без статичних секретів узагалі.
Як обрати:
- дві-три внутрішні інтеграції - підписані запити з ротацією секретів;
- багато сервісів, потрібні права й аудит - OAuth client credentials (у Laravel - Passport);
- мережа з нульовою довірою, високі вимоги - mTLS, часто разом із токенами.
Обов'язкове в будь-якому варіанті: найменші права для кожного сервісу, ротація секретів без простою (два дійсні ключі на час переходу), журнал викликів.