Впровадження JWT аутентифікації для веб-додатків
JWT (JSON Web Token) — компактний токен, що містить підписаний набір claims. На відміну від підходу на основі сесій, сервер не зберігає стан — усе необхідне вбудовано в сам токен. Горизонтальне масштабування без sticky sessions, без звернень до бази на кожному запиті.
Структура JWT
Токен складається з трьох частин, закодованих base64url, розділених крапкою:
header.payload.signature
// Декодований header:
{ "alg": "RS256", "typ": "JWT" }
// Декодований payload:
{
"sub": "user_42", // subject — ID користувача
"iss": "api.example.com", // issuer
"aud": "app.example.com", // audience
"iat": 1735600000, // issued at
"exp": 1735686400, // expires at (24 години)
"jti": "uuid-v4", // JWT ID для відкликання
"roles": ["admin"],
"plan": "pro"
}
Підпис — HMAC-SHA256 (HS256) або RSA/ECDSA (RS256/ES256). RS256 переважний: приватний ключ тільки на сервері видачі, публічний ключ можна розповсюджувати до будь-якого сервера ресурсів для верифікації.
Впровадження (Node.js + jose)
import { SignJWT, jwtVerify, generateKeyPair } from 'jose';
// Генеруємо пару ключів (один раз, зберігаємо у secrets)
const { privateKey, publicKey } = await generateKeyPair('RS256');
// Видаємо токени
async function issueTokens(userId: string, roles: string[]) {
const now = Math.floor(Date.now() / 1000);
const accessToken = await new SignJWT({ roles, plan: 'pro' })
.setProtectedHeader({ alg: 'RS256' })
.setSubject(userId)
.setIssuedAt(now)
.setExpirationTime('15m') // короткоживущий
.setJti(crypto.randomUUID())
.sign(privateKey);
const refreshToken = await new SignJWT({})
.setProtectedHeader({ alg: 'RS256' })
.setSubject(userId)
.setIssuedAt(now)
.setExpirationTime('30d') // довгоживущий
.setJti(crypto.randomUUID())
.sign(privateKey);
return { accessToken, refreshToken };
}
// Верифікація
async function verifyToken(token: string) {
const { payload } = await jwtVerify(token, publicKey, {
issuer: 'api.example.com',
audience: 'app.example.com',
});
return payload;
}
Стратегія Access Token + Refresh Token
Access token живе 15 хвилин — мінімальне вікно при компрометації. Refresh token — 30 днів, зберігається безпечно:
// При логіні — встановлюємо refresh token у httpOnly cookie
res.cookie('refresh_token', refreshToken, {
httpOnly: true, // недоступний JS
secure: true, // тільки HTTPS
sameSite: 'strict', // CSRF-захист
maxAge: 30 * 24 * 3600 * 1000,
path: '/api/auth/refresh', // cookie надсилається ТІЛЬКИ на /refresh
});
// Access token — у пам'яті (React state або модульна змінна), НІ в localStorage
// localStorage уразливий до XSS
Endpoint оновлення:
app.post('/api/auth/refresh', async (req, res) => {
const refreshToken = req.cookies.refresh_token;
if (!refreshToken) return res.status(401).json({ error: 'No refresh token' });
const payload = await verifyToken(refreshToken);
// Перевіряємо, чи не відкликаний токен (за jti)
const isRevoked = await redis.get(`revoked:${payload.jti}`);
if (isRevoked) return res.status(401).json({ error: 'Token revoked' });
const user = await db.user.findUnique({ where: { id: payload.sub } });
const tokens = await issueTokens(user.id, user.roles);
// Ротуємо refresh token
await redis.setex(`revoked:${payload.jti}`, 30 * 24 * 3600, '1');
res.cookie('refresh_token', tokens.refreshToken, { /* ... */ });
res.json({ access_token: tokens.accessToken });
});
Відкликання через Redis Blocklist
JWT — stateless, неможливо «відкликати» без додаткового сховища. Розв'язання — blocklist з TTL:
// Вихід
app.post('/api/auth/logout', authenticate, async (req, res) => {
const { jti, exp } = req.jwtPayload;
const ttl = exp - Math.floor(Date.now() / 1000);
if (ttl > 0) {
await redis.setex(`revoked:${jti}`, ttl, '1');
}
res.clearCookie('refresh_token', { path: '/api/auth/refresh' });
res.json({ success: true });
});
// Middleware верифікації з перевіркою blocklist
async function authenticate(req, res, next) {
const token = req.headers.authorization?.split(' ')[1];
const payload = await verifyToken(token);
const isRevoked = await redis.get(`revoked:${payload.jti}`);
if (isRevoked) return res.status(401).json({ error: 'Token revoked' });
req.jwtPayload = payload;
next();
}
Laravel — впровадження через tymon/jwt-auth
composer require tymon/jwt-auth
php artisan jwt:secret
// config/auth.php
'guards' => [
'api' => ['driver' => 'jwt', 'provider' => 'users'],
],
// AuthController
public function login(LoginRequest $request)
{
$credentials = $request->only(['email', 'password']);
if (!$token = auth('api')->attempt($credentials)) {
return response()->json(['error' => 'Invalid credentials'], 401);
}
return response()->json([
'access_token' => $token,
'token_type' => 'bearer',
'expires_in' => auth('api')->factory()->getTTL() * 60,
]);
}
public function refresh()
{
return response()->json([
'access_token' => auth('api')->refresh(),
]);
}
Що НЕ класти у JWT
JWT видимий для будь-кого, хто перехопить токен (перед перевіркою підпису) — підпис гарантує цілісність, не конфіденційність. Не класти у payload:
- Паролі, секрети
- Дані про платежі
- Персональні дані понад необхідне (GDPR — мінімізація даних)
Оптимальний payload: sub (user_id), roles, plan, jti, стандартні claims.
Терміни
JWT auth з RS256, access+refresh токени, httpOnly cookie для refresh, Redis revocation, logout: 3–5 днів. З автооновленням токена у React (silent refresh), підтримкою кількох пристроїв, аудит-логом сесій: 1–2 тижні.







