JWT — JSON Web Tokens
Lift постачає самодостатню реалізацію JWT: кодування/декодування, HS256–HS512 (симетричні) і RS256–RS512 (асиметричні), плавний білдер Claims та готовий до використання middleware для Bearer-автентифікації. Нуль зовнішніх залежностей — чистий PHP плюс ext-openssl для RSA.
Ментальна модель: JWT — це
{header}.{payload}.{signature}— три сегменти base64url, розділені крапками. Підпис доводить, що корисне навантаження не було підроблене. Якщо ви довіряєте підпису, ви довіряєте корисному навантаженню.
Коли використовувати JWT
- Безстанова автентифікація API (мобільні застосунки, SPA, сервіс-сервіс).
- Короткоживучі підписані квитки (посилання скидання пароля, підтвердження email, вхід за magic-link).
- Довіра між сервісами (один із ваших сервісів підписує токен; інший перевіряє його, не розділяючи стан БД).
Коли не використовувати їх:
- Серверно-рендеровані застосунки із session-cookie — використовуйте Сесії. Вони чисто обробляють відкликання.
- Зберігання чутливих даних — корисні навантаження JWT закодовані в base64, не зашифровані. Будь-хто, у кого є токен, може їх прочитати. (Для зашифрованих токенів див. JWE — ця бібліотека його не реалізує.)
Приклад за 30 секунд
use Lift\Jwt\Jwt;
use Lift\Jwt\Claims;
$jwt = new Jwt(secret: $_ENV['JWT_SECRET']);
// Випустити токен
$token = $jwt->encode(
Claims::make()
->subject('user_42')
->expiresIn(3600)
->extra(['role' => 'admin'])
->toArray()
);
// Пізніше — перевірити й декодувати
try {
$payload = $jwt->decode($token);
// $payload['sub'] === 'user_42'
// $payload['role'] === 'admin'
} catch (\Lift\Jwt\JwtException $e) {
// минув, підроблений, невірний ключ, спотворений, …
}
Алгоритми
| Варіант enum | Тип | Використовувати, коли |
|---|---|---|
JwtAlgorithm::HS256 |
HMAC SHA-256 | Однопроцесні застосунки. І видавець, і перевіряч розділяють один секрет. |
JwtAlgorithm::HS384 |
HMAC SHA-384 | Те саме, більший дайджест. Потрібно рідко. |
JwtAlgorithm::HS512 |
HMAC SHA-512 | Те саме, більший дайджест. Потрібно рідко. |
JwtAlgorithm::RS256 |
RSA SHA-256 | Багатосервісні. Приватний ключ підписує; публічний перевіряє. |
JwtAlgorithm::RS384 |
RSA SHA-384 | Те саме. |
JwtAlgorithm::RS512 |
RSA SHA-512 | Те саме. |
Симетричні (HS*) — просто
$jwt = new Jwt(
secret: $_ENV['JWT_SECRET'],
algo: JwtAlgorithm::HS256, // за замовчуванням
);
Секрет має бути щонайменше 32 випадкових байти. Згенеруйте його один раз:
php -r 'echo base64_encode(random_bytes(64));'
Асиметричні (RS*) — для розподілених систем
Видавець тримає приватний ключ. Перевірячам потрібен лише публічний ключ. Скомпрометуйте публічний ключ — нічого не станеться; зловмисник усе одно не зможе підробити токени.
// Видавець
$issuer = new Jwt(
algo: JwtAlgorithm::RS256,
privateKey: file_get_contents('/keys/private.pem'),
);
$token = $issuer->encode($payload);
// Перевіряч (потрібен лише публічний ключ)
$verifier = new Jwt(
algo: JwtAlgorithm::RS256,
publicKey: file_get_contents('/keys/public.pem'),
);
$payload = $verifier->decode($token);
Згенеруйте пару ключів:
openssl genpkey -algorithm RSA -out private.pem -pkeyopt rsa_keygen_bits:2048
openssl rsa -in private.pem -pubout -out public.pem
Білдер Claims
Стандартні імена claim у JWT — трилітерні коди. Плавний білдер пише їх повністю:
$payload = Claims::make()
->subject('user_42') // sub
->issuer('https://api.example.com') // iss
->audience('https://app.example.com') // aud (або масив)
->id(Uuid::v7()) // jti — унікальний id токена
->issuedAt() // iat — за замовчуванням now()
->expiresIn(3600) // exp — через 1 годину від поточного моменту
->notBefore(time() + 60) // nbf — дійсний лише після цього часу
->extra([
'role' => 'admin',
'email' => '[email protected]',
])
->toArray();
Білдер можна повністю пропустити й передати сирий масив — це просто синтаксичний цукор.
Що Lift валідує автоматично
Коли ви викликаєте $jwt->decode($token), він перевіряє:
| Claim | Поведінка |
|---|---|
| Підпис | Завжди перевіряється проти налаштованого ключа/секрета. |
exp |
Токен відхиляється, якщо now > exp (з допуском leeway). |
nbf |
Токен відхиляється, якщо now < nbf (з допуском leeway). |
iss |
Перевіряється, лише якщо ви налаштували issuer: на екземплярі Jwt. |
aud |
Перевіряється, лише якщо ви налаштували audience: на екземплярі Jwt. |
Усе інше (sub, власні claim) не валідується бібліотекою — ви перевіряєте їх у своєму обробнику / middleware.
Примусова перевірка issuer / audience
$jwt = new Jwt(
secret: $_ENV['JWT_SECRET'],
issuer: 'https://auth.example.com',
audience: 'https://api.example.com',
);
// decode() відхилить будь-який токен, чиї `iss` і `aud` не збігаються
Це захищає вас від повторного використання токена між сервісами — токен, випущений для billing API, не можна використати проти admin API.
Розходження годинників
Якщо годинник вашого перевіряча відстає від годинника видавця на пару секунд, щойно випущені токени можуть на короткий час виглядати «ще не дійсними». Дозвольте невеликий допуск:
$jwt = new Jwt(secret: $_ENV['JWT_SECRET'], leeway: 30); // ±30 секунд
Middleware
Готова Bearer-токен автентифікація:
use Lift\Jwt\JwtMiddleware;
$jwt = new Jwt(secret: $_ENV['JWT_SECRET']);
$app->use(new JwtMiddleware($jwt));
// У будь-якому обробнику:
$app->get('/me', function (Request $req) {
$claims = $req->getAttribute('jwt'); // декодоване корисне навантаження
return ['user_id' => $claims['sub']];
});
Що він робить:
- Читає
Authorization: Bearer <token>. - Викликає
$jwt->decode($token). - За успіху: прикріплює корисне навантаження до
$reqяк атрибут'jwt', викликає наступний обробник. - За невдачі: повертає JSON
401 Unauthorized+ заголовокWWW-Authenticate: Bearer.
Пропуск публічних маршрутів
Middleware може ігнорувати точні шляхи — корисно, коли ви монтуєте його глобально:
$app->use(new JwtMiddleware(
jwt: $jwt,
except: ['/login', '/register', '/healthz', '/openapi.json'],
));
Для гнучкішого пропуску (regex-шляхи, публічні групи) застосовуйте погрупово замість глобального:
$app->group('/api', function ($g) use ($jwt) {
$g->get('/me', /* … */);
$g->get('/orders', /* … */);
})->middleware(new JwtMiddleware($jwt));
Власне ім’я атрибута
new JwtMiddleware($jwt, attribute: 'auth');
// пізніше: $req->getAttribute('auth')
Наскрізний приклад: вхід + захищений маршрут
use Lift\App;
use Lift\Crypto\Hasher;
use Lift\Http\Request;
use Lift\Http\Response;
use Lift\Jwt\Claims;
use Lift\Jwt\Jwt;
use Lift\Jwt\JwtMiddleware;
$app = new App();
$jwt = new Jwt(secret: $_ENV['JWT_SECRET']);
$hasher = new Hasher();
$app->instance(Jwt::class, $jwt);
$app->instance(Hasher::class, $hasher);
// 1. Вхід — публічний
$app->post('/login', function (Request $req) use ($jwt, $hasher, $db) {
$data = $req->validate([
'email' => 'required|email',
'password' => 'required|string',
]);
$user = $db->table('users')->where('email', $data['email'])->first();
if ($user === null || !$hasher->verify($data['password'], $user['password_hash'])) {
return Response::json(['error' => 'Invalid credentials'], 401);
}
$token = $jwt->encode(
Claims::make()
->subject((string) $user['id'])
->expiresIn(3600)
->extra(['email' => $user['email'], 'role' => $user['role']])
->toArray()
);
return Response::json(['token' => $token, 'expires_in' => 3600]);
});
// 2. Захистити все під /api за допомогою middleware
$app->group('/api', function ($g) {
$g->get('/me', function (Request $req) {
$claims = $req->getAttribute('jwt');
return ['id' => $claims['sub'], 'email' => $claims['email']];
});
$g->get('/admin/stats', function (Request $req) {
$claims = $req->getAttribute('jwt');
if (($claims['role'] ?? '') !== 'admin') {
throw new \Lift\Exception\ForbiddenException();
}
return ['users' => 42, 'orders' => 1337];
});
})->middleware(new JwtMiddleware($jwt));
$app->run();
Використання на клієнті:
# 1. Вхід
TOKEN=$(curl -s -X POST http://localhost:8000/login \
-H 'Content-Type: application/json' \
-d '{"email":"[email protected]","password":"hunter2"}' | jq -r .token)
# 2. Використати токен
curl http://localhost:8000/api/me \
-H "Authorization: Bearer $TOKEN"
Refresh-токени
JWT безстановий — раз випущений токен не можна «відкликати» до його завершення. Стандартне розв’язання:
- Access-токени — короткоживучі (5–15 хв) JWT.
- Refresh-токени — довгоживучі (дні/тижні) непрозорі ідентифікатори, що зберігаються у вашій БД. Коли access-токен завершується, клієнт обмінює refresh-токен на новий access-токен. Ви можете відкликати refresh-токен, видаливши рядок.
Начерк:
$app->post('/refresh', function (Request $req) use ($jwt, $db) {
$data = $req->validate(['refresh_token' => 'required|string']);
$row = $db->table('refresh_tokens')
->where('token', hash('sha256', $data['refresh_token']))
->where('expires_at', '>', date('Y-m-d H:i:s'))
->first();
if ($row === null) {
throw new \Lift\Exception\UnauthorizedException("Bad refresh token");
}
// Опційно: ротація — видалити старий refresh-токен, випустити нову пару.
$access = $jwt->encode(Claims::make()->subject((string)$row['user_id'])->expiresIn(900)->toArray());
return ['access_token' => $access];
});
Зберігання токенів на стороні клієнта
SPA / браузер: розміщуйте access-токен у пам’яті (JS-змінна, ніколи не localStorage). Refresh-токен — у cookie HttpOnly; Secure; SameSite=Strict. Ця комбінація стійка до XSS (access-токен недоступний шкідливим скриптам) і до CSRF (cookie не може бути викрадена іншими сайтами).
Мобільні: захищене сховище платформи (Keychain / Keystore).
Сервіс-сервіс: у пам’яті процесу; перечитуйте з менеджера секретів під час ротації.
Чек-лист безпеки
- ✅ HMAC-секрет — щонайменше 32 випадкових байти, зберігається в env-змінних / менеджері секретів, ніколи в git.
- ✅ Короткий
exp(≤ 1 година для access-токенів). - ✅ Завжди валідуйте
issіaud, коли у вас кілька сервісів / клієнтів. - ✅ Завжди фіксуйте
algoна стороні сервера — ніколи не дозволяйте заголовкуalgтокена вирішувати. - ✅ Ротуйте RSA-ключі щонайменше щорічно. Підтримуйте кілька публічних ключів у вікні ротації (побудуйте невеликий
KeySelector, якщо це потрібно). - ❌ Ніколи не довіряйте корисному навантаженню до того, як
decode()успішно повернеться. - ❌ Ніколи не розміщуйте паролі, сирі персональні дані чи session-cookie всередині JWT — вони видимі у відкритому вигляді.
- ❌ Ніколи не використовуйте
alg: none— enum Lift його навіть не включає, але майте на увазі, що деякі бібліотеки так.
Часті підводні камені
| Симптом | Причина | Виправлення |
|---|---|---|
Token has expired одразу після випуску |
Годинники видавця й перевіряча розходяться | Установіть leeway: 30 на перевірячі. |
| 401 на кожному запиті після деплою | JWT_SECRET змінився |
Ротуйте обережно: токени, випущені зі старим секретом, помирають миттєво. Використовуйте rolling-деплої / підтримку двох ключів. |
Missing or malformed Authorization header. |
Заголовок Bearer token (правильно), але клієнт забув пробіл або використовує bearer (нижній регістр OK) |
Перевірка Lift регістрозалежна для Bearer — переконайтеся, що клієнт надсилає рівно цей префікс. |
Поле role токена оновлено на сервері, але користувач усе ще бачить стару роль |
JWT безстановий; старі токени працюють до exp |
Короткий exp + refresh-токени; або підтримуйте серверну «версію сесії», яку клієнт має повертати. |
RSA-перевірка повертає false для валідного токена |
Невірний ключ або PEM з переносами рядків CRLF | Переекспортуйте ключі; перевірте через openssl rsa -in private.pem -check. |
| Токен величезний | Ви напхали в нього багато claim | Тримайте корисні навантаження крихітними (sub + кілька id). Токен надсилається на кожному запиті. |
Шпаргалка
// Налаштувати
$jwt = new Jwt(
secret: $_ENV['JWT_SECRET'],
algo: JwtAlgorithm::HS256,
leeway: 30,
issuer: 'https://auth.example.com',
audience: 'https://api.example.com',
);
// Випустити
$token = $jwt->encode(
Claims::make()->subject('user_42')->expiresIn(3600)->extra([...])->toArray()
);
// Перевірити
try { $payload = $jwt->decode($token); }
catch (JwtException $e) { /* 401 */ }
// Middleware
$app->use(new JwtMiddleware($jwt, except: ['/login']));
$claims = $req->getAttribute('jwt');