Питання на співбесіді з API
Питання з реальних співбесід з відповідями: Laravel і PHP, бази даних, JavaScript і фронтенд, Git, Docker, API, безпека й архітектура. Тими самими темами, що й тести.
100 питань
HTTP-клієнт Laravel (обгортка над Guzzle) робить запити до сторонніх API коротко й зручно:
use Illuminate\Support\Facades\Http;
$response = Http::withToken(config('services.nova_poshta.key'))
->acceptJson()
->connectTimeout(3)
->timeout(10)
->get('https://api.example.com/v1/parcels/123');
$status = $response->json('data.status');
Тайм-аути - обов'язкові для свідомого вибору:
connectTimeout- скільки чекати на встановлення з'єднання (за замовчуванням 10 секунд);timeout- скільки чекати на всю відповідь (за замовчуванням 30 секунд).
Тридцять секунд очікування повільного API в обробнику запиту користувача - це тридцять секунд зайнятого процесу PHP і користувач, що дивиться на спінер. Для запитів у веб-обробнику тайм-аути варто ставити короткими.
Помилки - клієнт не кидає винятки сам. Відповідь 404 чи 500 - це звичайний об'єкт Response, і код спокійно продовжить роботу з порожніми даними. Перевіряйте явно:
if ($response->failed()) { // 4xx або 5xx
// ...
}
$response->successful(); // 2xx
$response->clientError(); // 4xx
$response->serverError(); // 5xx
$data = $response->throw()->json(); // кинути RequestException на 4xx/5xx
Два різні типи збою:
Illuminate\Http\Client\RequestException- сервер відповів з помилкою (throw());Illuminate\Http\Client\ConnectionException- відповіді не було взагалі: тайм-аут, DNS, відмова в з'єднанні. Кидається завжди.
Що ще варто зробити відразу:
- ключі й адреси - у
config/services.phpз.env, не в коді; - базову адресу й автентифікацію зібрати в одному місці (макрос або клас-клієнт), щоб не дублювати по проєкту;
- логувати збої з контекстом (ендпойнт, статус, ідентифікатор запиту), але без токенів і персональних даних;
- не довіряти формату відповіді: сторонній API може змінити поле -
$response->json('data.status')повернеnull, і це треба обробити.
Довгі чи масові виклики - у черзі, а не в запиті користувача.
Тести не повинні ходити в реальні сторонні API: вони стають повільними, нестабільними, залежать від мережі й можуть створювати справжні платежі чи відправляти справжні SMS.
Http::fake() підміняє відповіді:
use Illuminate\Http\Client\Request;
use Illuminate\Support\Facades\Http;
it('stores the tracking status', function () {
Http::fake([
'api.example.com/v1/parcels/*' => Http::response(['data' => ['status' => 'delivered']]),
'api.example.com/*' => Http::response(status: 404),
]);
app(ParcelTracker::class)->refresh($parcel);
expect($parcel->fresh()->status)->toBe('delivered');
Http::assertSent(fn (Request $request) =>
$request->url() === 'https://api.example.com/v1/parcels/123'
&& $request->hasHeader('Authorization')
);
});
Сценарії збоїв - так само просто:
Http::fake(['api.example.com/*' => Http::response(status: 503)]);
// послідовність: спершу помилка, потім успіх - перевірка повторів
Http::fake([
'api.example.com/*' => Http::sequence()
->pushStatus(503)
->push(['data' => ['status' => 'delivered']]),
]);
// мережевий збій (ConnectionException)
Http::fake(['api.example.com/*' => Http::failedConnection()]);
Http::preventStrayRequests() - будь-який запит без підготовленої фейкової відповіді кидає виняток. Найкраще ввімкнути глобально в базовому класі тестів чи Pest.php: тоді жоден тест випадково не піде в мережу, навіть якщо хтось забув Http::fake.
Корисні перевірки:
Http::assertSentCount(2)- скільки запитів зроблено;Http::assertNothingSent()- запитів не було (наприклад, через кеш);Http::assertSentInOrder([...])- порядок.
Реалістичні відповіді. Найкраще зберігати справжні (знеособлені) відповіді API у файлах-фікстурах і віддавати їх з Http::response(json_decode(file_get_contents(...))). Тести тоді перевіряють розбір саме того формату, що надсилає провайдер.
Пісочниці провайдерів (тестові ключі Stripe, LiqPay, Monobank) - для ручної перевірки й рідкісних інтеграційних тестів, які запускаються окремо від основного набору, а не для кожного прогону CI.
Обмеження фейків: вони перевіряють ваш код проти вашого уявлення про API. Якщо провайдер змінив формат, фейки цього не помітять - тут допомагають контрактні тести чи моніторинг помилок у продакшені.
Сторонній API - найменш контрольована частина системи: він буває повільним, недоступним, обмежує частоту запитів. Якщо викликати його прямо в обробнику запиту користувача, всі його проблеми стають вашими.
Що відбувається без черги:
public function store(Request $request)
{
$order = Order::create($request->validated());
Http::post('https://crm.example.com/api/deals', [...]); // 8 секунд...
Http::post('https://sms.example.com/send', [...]); // ...і впав з 503
return redirect()->route('orders.show', $order);
}
- користувач чекає, поки відповідять усі сторонні сервіси;
- падіння CRM ламає оформлення замовлення, хоча замовлення вже створене;
- процеси PHP-FPM зайняті очікуванням мережі - під навантаженням вони закінчуються, і сайт гальмує повністю;
- повторити невдалий виклик ніхто не спробує.
З чергою:
public function store(Request $request)
{
$order = Order::create($request->validated());
SyncOrderToCrm::dispatch($order)->afterCommit();
SendOrderSms::dispatch($order)->afterCommit();
return redirect()->route('orders.show', $order);
}
Що дає черга:
- швидка відповідь користувачу незалежно від сторонніх сервісів;
- повторні спроби з затримкою (
$tries,backoff()) - тимчасові збої вирішуються самі; - ізоляція: окрема черга для інтеграцій з власними воркерами - повільний провайдер не затримує листи чи генерацію звітів;
- обмеження частоти через middleware джоби (
RateLimited,ThrottlesExceptions) - під ліміти провайдера; - видимість: невдалі джоби в
failed_jobsчи Horizon, їх можна повторити вручну.
afterCommit() - джоба потрапить у чергу лише після коміту транзакції. Інакше воркер може взяти її раніше, ніж замовлення з'явиться в базі.
Коли синхронний виклик виправданий: результат потрібен одразу для відповіді (перевірка адреси доставки, розрахунок вартості, ініціалізація оплати з перенаправленням). Тоді - короткий тайм-аут і зрозуміле повідомлення користувачу при збої.
Джоба має бути ідемпотентною: повтор не повинен створити в CRM дві угоди. Зберігайте зовнішній ідентифікатор після першого успішного виклику і перевіряйте його перед створенням.
Базове правило: ключі не потрапляють у код і в Git. Вони живуть у змінних оточення, а код звертається до них через конфігурацію.
# .env - у .gitignore
STRIPE_SECRET=sk_live_...
NOVA_POSHTA_API_KEY=...
// config/services.php
'nova_poshta' => [
'key' => env('NOVA_POSHTA_API_KEY'),
'url' => env('NOVA_POSHTA_URL', 'https://api.novaposhta.ua/v2.0/json/'),
],
// у коді
Http::withToken(config('services.nova_poshta.key'));
Чому config(), а не env() у коді: після php artisan config:cache файл .env більше не читається, і env() поза конфігураційними файлами поверне null.
Що ще важливо:
.env.exampleмістить назви змінних без значень - новий розробник бачить, що потрібно налаштувати;- різні ключі для середовищ: тестові ключі пісочниці на локальній машині й staging, бойові - лише в продакшені;
- мінімальні права ключа: якщо провайдер дозволяє обмежити ключ (лише читання, певні IP, певні операції), - обмежувати;
- ротація: план заміни ключа без простою (новий ключ → деплой → відкликання старого) і обов'язкова заміна, якщо ключ міг витекти;
- не логувати секрети: заголовки
Authorization, тіла з ключами. У PHP 8.2+ атрибут#[\SensitiveParameter]приховує значення параметра в стеку помилки - Laravel використовує його, наприклад, уwithToken().
Зашифрований .env у репозиторії - вбудований механізм Laravel:
php artisan env:encrypt --env=production # створює .env.production.encrypted
php artisan env:decrypt --env=production --key=...
Зашифрований файл можна комітити, а ключ розшифрування передається лише в CI чи на сервер. Зручно для невеликих команд без окремого сховища секретів.
Для більших систем - менеджери секретів (Vault, AWS Secrets Manager, Doppler, секрети платформи деплою), де є аудит доступу й централізована ротація.
Токени користувачів до сторонніх сервісів (OAuth-доступ до їхніх Google чи GitHub) - це вже дані, а не конфігурація: зберігаються в базі зашифрованими (каст encrypted в Eloquent).
Ключ, що потрапив у Git, вважається скомпрометованим, навіть якщо коміт видалили: історія й форки лишаються. Тільки відкликання.
Задача: дізнатися, що в сторонньому сервісі щось змінилося - оплата пройшла, посилка доставлена, клієнт оновився в CRM.
Опитування (polling) - ваш застосунок періодично питає API:
Schedule::job(new RefreshParcelStatuses)->everyFifteenMinutes();
- плюси: просто, працює з будь-яким API, ви контролюєте частоту й момент; не потрібна публічна адреса;
- мінуси: затримка до інтервалу опитування; більшість запитів - марні («нічого не змінилося»); з'їдає ліміти частоти провайдера; погано масштабується на тисячі об'єктів.
Вебхуки - провайдер сам надсилає запит на ваш URL, коли подія сталася:
- плюси: майже миттєво; немає марних запитів; масштабується;
- мінуси: потрібна публічна HTTPS-адреса; треба перевіряти підпис; провайдер може не доставити (збій у нього, ваш простій) - подію буде втрачено або доставлено пізніше; дублікати й зміна порядку.
На практиці найнадійніше - поєднання:
- вебхук - як сигнал для швидкої реакції;
- періодичне опитування - як страховка (звірка): раз на годину чи добу перевірити об'єкти в «незавершених» станах (оплата «очікується» понад годину) і підтягнути їхній актуальний статус.
Так система не залежить від того, що кожен вебхук дійде.
Правила обробки вхідних вебхуків:
- перевірити підпис, відповісти
200швидко, а обробку - в чергу; - ідемпотентність за
idподії: дублікат не повинен обробитися двічі; - не покладатися на порядок подій - порівнювати з поточним станом чи запитувати свіжі дані з API;
- для локальної розробки - тунель (Expose, ngrok) чи CLI провайдера, що пересилає події на
localhost.
Коли лише опитування: провайдер не має вебхуків; дані змінюються рідко і затримка неважлива; застосунок не має публічної адреси (внутрішня мережа).
Коли лише вебхуки (без звірки): втрата події некритична, або провайдер гарантує повторну доставку довго й надійно, а у вас є журнал отриманих подій.
Клієнту API потрібно не лише знати, що пішло не так, а й обробити це програмно. Для цього помилки мають бути в одному передбачуваному форматі на весь API.
Problem Details (RFC 9457, замінив RFC 7807) - стандартний формат з типом application/problem+json:
{
"type": "https://api.example.com/problems/insufficient-funds",
"title": "Недостатньо коштів",
"status": 422,
"detail": "На рахунку 30 грн, а списання - 50 грн.",
"instance": "/payments/8f3a",
"balance": 3000
}
type- URI типу помилки, стабільний ідентифікатор для програмної обробки (бажано - з документацією за цією адресою).title- короткий опис типу, однаковий для всіх його випадків.status- HTTP-код (дублює заголовок для зручності).detail- пояснення саме цього випадку.instance- ідентифікатор конкретного випадку.- Можна додавати власні поля - як
balanceвище чи список помилок валідації за полями.
Принципи, незалежно від формату:
- Правильний HTTP-код - формат тіла його не замінює.
- Машиночитний код помилки (
typeчиcode), а не лише текст: текст змінюють і перекладають, а клієнти на нього не мають покладатися. - Помилки валідації за полями, щоб клієнт підсвітив поля форми.
- Жодних стек-трейсів, SQL і шляхів до файлів у відповіді на проді.
- ID запиту в тілі чи заголовку - щоб знайти помилку в логах за зверненням клієнта.
Докладніше в документації: RFC 9457: Problem Details for HTTP APIs
Основні правила:
- Іменники, а не дієслова: дія задається HTTP-методом.
GET /orders,POST /orders,DELETE /orders/42- а не/getOrders,/createOrder. - Множина для колекцій:
/ordersі/orders/42- узгоджено для всього API. - Вкладеність для відношень, але неглибока:
/users/42/orders- так;/users/42/orders/7/items/3/reviews- ні. Ресурс з власним ID краще адресувати напряму:/order-items/3. - Нижній регістр і дефіси:
/payment-methods, а не/paymentMethodsчи/payment_methods. - Фільтрація, сортування, пагінація - у query-параметрах:
/orders?status=paid&sort=-created_at&page[size]=20. - Стабільні ідентифікатори в URL: ID чи UUID, а не назви, які можуть змінитися.
Дії, що не вкладаються в CRUD:
- як під-ресурс стану:
POST /orders/42/cancellation; - як дія-під-ресурс:
POST /orders/42/cancel- прагматично й зрозуміло, і так роблять великі API (Stripe, GitHub); - головне - узгодженість у межах API.
Відповіді:
- Узгоджене іменування полів (
snake_caseчиcamelCase- одне на весь API). - Дати - в ISO 8601 з часовим поясом.
- Гроші - цілими числами в мінімальних одиницях або рядками, з валютою.
- Посилання на пов'язані ресурси чи їхні ID, а не дублювання цілих об'єктів без потреби.
Найважливіше - передбачуваність: розробник, побачивши два ендпоінти, має вгадати третій. Опис в OpenAPI допомагає тримати цю узгодженість і генерувати клієнти.
Вкладений ресурс відображає відношення «належить до» в URL:
GET /posts/42/comments # коментарі конкретного поста
POST /posts/42/comments # додати коментар до поста
Коли вкладеність доречна:
- дочірній ресурс не має сенсу без батька (коментар без поста, позиція без замовлення);
- створення - URL одразу задає батька, і його не треба передавати в тілі;
- колекція в контексті: «замовлення цього клієнта» - природний запит.
Де зупинитися:
1. Не глибше одного рівня. /users/7/orders/42/items/3/discounts/1 - важко читати, і всі проміжні ідентифікатори доводиться знати й перевіряти. Якщо в дочірнього ресурсу є власний унікальний id, на ньому можна працювати напряму:
GET /posts/42/comments # колекція - вкладена
GET /comments/15 # окремий коментар - плаский
PATCH /comments/15
DELETE /comments/15
Це неглибока вкладеність (shallow nesting): вкладені лише ті маршрути, де батько потрібен (колекція й створення). У Laravel - Route::resource('posts.comments', CommentController::class)->shallow().
2. Не для фільтрації за довільними полями. /users/7/orders - добре, але /status/paid/orders - ні: це фільтр, а не ієрархія, - GET /orders?status=paid.
3. Не для зв'язків «багато-до-багатьох» без явної власності: /tags/5/posts і /posts/42/tags - обидва варіанти доречні як точки входу, але сам зв'язок - окремий ресурс.
Безпека - головна пастка вкладених URL:
GET /posts/42/comments/15
Перевірити треба не лише, що користувач має доступ до поста 42, а й що коментар 15 належить посту 42. Інакше зловмисник підставить пост, до якого має доступ, і чужий коментар. У Laravel для цього - scopeBindings():
Route::get('/posts/{post}/comments/{comment}', ...)->scopeBindings();
Тоді коментар шукається через $post->comments(), і чужий дасть 404.
Консистентність: якщо вже обрано схему (вкладені колекції + плаский доступ до елементів), - дотримуватися її для всіх ресурсів API.
Реальні API мають операції, які погано виражаються через «створити, прочитати, оновити, видалити»: скасувати замовлення, опублікувати статтю, надіслати рахунок, перерахувати знижку.
Варіант 1 - зміна стану через PATCH:
PATCH /orders/42
{"status": "cancelled"}
Підходить, коли дія справді лише змінює поле. Проблеми починаються, коли:
- переходу потрібні додаткові дані (причина скасування, сума повернення);
- переход має побічні ефекти (повернення коштів, листи, звільнення товару на складі);
- дозволені не всі переходи (не можна скасувати відправлене замовлення).
Тоді PATCH з полем status ховає важливу бізнес-операцію за «оновленням поля», і валідація переходів розмазується.
Варіант 2 - дія як підресурс (найпоширеніший):
POST /orders/42/cancel {"reason": "Помилка в адресі"}
POST /articles/7/publish
POST /invoices/15/send
Дієслово в URL - свідомий виняток із «лише іменників»: операція явна, має власні параметри, права й журнал аудиту.
Варіант 3 - дія як ресурс-іменник:
POST /orders/42/cancellations # створити «скасування»
POST /refunds {"order_id": 42, "amount": 1000}
Корисно, коли у дії є власний життєвий цикл (повернення коштів може бути в обробці, відхиленим, завершеним) і її треба переглядати потім.
Google API (AIP-136) використовує синтаксис з двокрапкою: POST /orders/42:cancel. Він чітко відділяє дію від ієрархії ресурсів, але в Laravel-проєктах частіше бачать варіант 2.
Правила для власних дій:
- метод
POST- дія неідемпотентна чи має побічні ефекти. Для ідемпотентних (повторне «опублікувати» нічого не змінює) варто це задокументувати; - відповідь - оновлений ресурс (
200з тілом) або202 Accepted, якщо дія асинхронна; - недопустимий перехід -
409 Conflict(замовлення вже відправлено) з поясненням у тілі; - права - окремо для кожної дії (
can:cancel,order), а не загальне «можна оновлювати».
Головне - послідовність: один стиль для всіх дій в API.
Варіант 1 - multipart/form-data через сервер API:
POST /api/documents
Content-Type: multipart/form-data; boundary=...
title=Договір
file=<бінарні дані>
$request->validate(['file' => ['required', 'file', 'mimes:pdf', 'max:10240']]);
$path = $request->file('file')->store('documents', 's3');
Просто, валідація й збереження в одному запиті. Але весь файл проходить через сервер застосунку: займає процес PHP на весь час завантаження, обмежений upload_max_filesize/post_max_size і тайм-аутами, а для великих файлів - ще й проксі (client_max_body_size у Nginx).
Варіант 2 - підписаний URL і пряме завантаження в сховище (S3, R2):
- клієнт просить дозвіл:
POST /api/uploads {"filename": "contract.pdf", "size": 52428800}; - сервер перевіряє права й параметри і повертає тимчасовий підписаний URL:
['url' => $url, 'headers' => $headers] = Storage::disk('s3')
->temporaryUploadUrl("uploads/{$id}.pdf", now()->plus(minutes: 5));
- клієнт завантажує файл напряму в сховище (
PUT $url); - клієнт повідомляє API:
POST /api/documents {"upload_id": "..."}- сервер перевіряє, що файл справді з'явився, його розмір і тип, і створює запис.
Переваги прямого завантаження: сервер застосунку не тримає з'єднання, немає лімітів PHP, сховище масштабується саме, можна завантажувати частинами (multipart upload S3) з продовженням після збою.
Що обов'язково для безпеки:
- короткий термін дії URL і фіксований шлях - клієнт не обирає, куди писати;
- перевірка після завантаження: розмір і тип - не довіряти тому, що клієнт заявив у кроці 1. Підозрілі файли - в карантин чи на антивірусну перевірку;
- очищення завантажених, але не підтверджених файлів (правило життєвого циклу бакета);
- приватний бакет і видача файлів теж через підписані URL.
Варіант 3 - base64 у JSON: зручно для дуже малих файлів (аватар-мініатюра), але +33% до розміру й весь файл у пам'яті - для решти погано.
Як обрати: невеликі файли (до кількох мегабайтів) - multipart; великі, багато одночасних завантажень, мобільні клієнти з нестабільною мережею - пряме завантаження.
Докладніше в документації: Laravel: тимчасові URL для завантаження
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.
Якщо секрет усе ж потрапив у лог чи репозиторій - вважати його скомпрометованим і відкликати, а не лише видалити запис.
Питання з реальних технічних співбесід - 100 питань у 7 темах, розібраних із відповідями. Нижче - розбивка за рівнями та темами, якщо хочете звузити підготовку.
Готуєтесь до співбесіди не просто так: зараз на сайті 146 відкритих вакансій Laravel і PHP. Переглянути вакансії