Реализация 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 для revocation
"roles": ["admin"],
"plan": "pro"
}
Подпись — HMAC-SHA256 (HS256) или RSA/ECDSA (RS256/ES256). RS256 предпочтительнее: приватный ключ только на сервере выдачи, публичный можно раздать любому ресурсному серверу для верификации.
Реализация (Node.js + jose)
import { SignJWT, jwtVerify, generateKeyPair } from 'jose';
// Генерация ключевой пары (один раз, храним в секретах)
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 или module variable), НЕ в localStorage
// localStorage уязвим к XSS
Эндпоинт обновления:
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 });
});
Revocation через Redis blocklist
JWT stateless — нельзя «отозвать» без дополнительного хранилища. Решение — blocklist с TTL:
// Logout
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 недели.







