Pino vs Winston в 2026: бенчмарки, настройка и выбор логгера для Node.js
JWT стал стандартом де-факто для аутентификации в SPA и микросервисах. Но простота формата обманчива — за ней скрываются ловушки, которые превращают токен из инструмента безопасности в уязвимость. Вот семь ошибок, которые я встречаю в продакшене снова и снова.
1. Хранение токена в localStorage
Самая распространённая ошибка — положить JWT в localStorage. Любой XSS на странице получает полный доступ к токену:
// Плохо: любой инжектированный скрипт украдёт токен
localStorage.setItem('token', jwt);
// Хорошо: httpOnly cookie недоступна из JS
res.cookie('access_token', jwt, {
httpOnly: true,
secure: true,
sameSite: 'strict',
maxAge: 15 * 60 * 1000 // 15 минут
});
httpOnly cookie не панацея — нужна защита от CSRF (SameSite + CSRF-токен). Но она закрывает весь класс XSS-атак на кражу сессии.
2. Отсутствие валидации алгоритма (alg:none)
JWT-заголовок содержит поле alg, указывающее алгоритм подписи. Атака «alg:none» — злоумышленник подменяет заголовок на {"alg": "none"} и отправляет токен без подписи. Если сервер доверяет полю из токена:
// УЯЗВИМО: библиотека сама выбирает алгоритм из заголовка
const payload = jwt.verify(token, secret);
// БЕЗОПАСНО: явно указываем допустимые алгоритмы
const payload = jwt.verify(token, secret, {
algorithms: ['HS256'] // или ['RS256'] для асимметричной подписи
});
Ещё опаснее — путаница RS256/HS256. Если сервер настроен на RS256 (асимметричный), атакующий может подписать токен публичным ключом с alg: HS256. Сервер использует публичный ключ как symmetric secret и примет подделку.
3. Слишком долгий TTL без refresh-ротации
Access token с TTL в 24 часа или неделю — это окно, в течение которого украденный токен даёт полный доступ. Правильная схема:
// Access token — короткоживущий (5-15 минут)
const accessToken = jwt.sign(payload, secret, { expiresIn: '15m' });
// Refresh token — долгоживущий, но одноразовый
const refreshToken = crypto.randomUUID();
await redis.set(`refresh:${refreshToken}`, userId, 'EX', 7 * 86400);
// При ротации: старый refresh удаляется, выдаётся новый
async function rotateRefresh(oldToken: string) {
const userId = await redis.get(`refresh:${oldToken}`);
if (!userId) throw new UnauthorizedError('Token revoked');
await redis.del(`refresh:${oldToken}`);
const newRefresh = crypto.randomUUID();
await redis.set(`refresh:${newRefresh}`, userId, 'EX', 7 * 86400);
return { accessToken: signAccess(userId), refreshToken: newRefresh };
}
Если refresh token используется дважды — это сигнал компрометации. Инвалидируйте всю цепочку.
4. Секрет в коде и слабый symmetric key
Хардкод секрета в исходниках — классика. Но даже если секрет в переменной окружения, он может быть слишком слабым:
// Плохо: короткий, словарный
const secret = 'my-jwt-secret';
// Плохо: предсказуемый
const secret = process.env.APP_NAME + '2024';
// Хорошо: минимум 256 бит энтропии для HS256
// Генерация: openssl rand -base64 32
const secret = process.env.JWT_SECRET; // dK8x...64 символа base64
Для продакшена рассмотрите RS256 с ротацией ключей через JWKS endpoint. Это позволяет менять ключи без инвалидации всех токенов.
5. Отсутствие revocation — logout не работает
JWT по дизайну stateless — сервер не хранит состояние. Но это значит, что «выйти из системы» клиент не может: токен валиден до истечения TTL. Решения:
// Вариант 1: Blacklist в Redis (для коротких access tokens)
async function logout(token: string) {
const decoded = jwt.decode(token) as JwtPayload;
const ttl = decoded.exp! - Math.floor(Date.now() / 1000);
if (ttl > 0) {
await redis.set(`blacklist:${token}`, '1', 'EX', ttl);
}
}
// В middleware проверяем:
async function authMiddleware(req: Request, res: Response, next: NextFunction) {
const token = extractToken(req);
if (await redis.exists(`blacklist:${token}`)) {
return res.status(401).json({ error: 'Token revoked' });
}
// ... verify token
}
Вариант 2: версионирование — храните tokenVersion в БД пользователя. При logout инкрементируете версию. В токене зашит номер версии, при проверке сравниваете.
6. Раздутый payload — проблемы с размером
JWT передаётся в каждом запросе. Если вы засунете в payload роли, пермишены, настройки профиля и историю заказов — токен раздуется до нескольких килобайт. Проблемы:
- HTTP-заголовки имеют лимит (8KB в nginx по умолчанию, 431 ошибка)
- Cookie ограничены 4KB на домен
- Каждый запрос тащит лишний трафик
// Плохо: всё в токене
const payload = {
sub: userId,
roles: ['admin', 'editor', 'viewer'],
permissions: ['read:posts', 'write:posts', 'delete:posts', ...50 items],
profile: { name, avatar, settings: {...} }
};
// Хорошо: минимум в токене, остальное — запрос к сервису
const payload = {
sub: userId,
role: 'admin' // одна основная роль
};
// Детальные пермишены — отдельный запрос или кэш на сервере
7. Игнорирование aud/iss claims
Claims aud (audience) и iss (issuer) — не декорация. Без их проверки токен, выданный для сервиса A, принимается сервисом B:
// При создании — указываем для кого токен
const token = jwt.sign(payload, secret, {
issuer: 'auth.myapp.com',
audience: 'api.myapp.com',
expiresIn: '15m'
});
// При проверке — требуем совпадения
const payload = jwt.verify(token, secret, {
issuer: 'auth.myapp.com',
audience: 'api.myapp.com',
algorithms: ['HS256']
});
В микросервисной архитектуре это критично. Без aud токен от публичного API может быть использован для доступа к внутреннему admin-сервису, если они делят один секрет.
Чеклист перед деплоем
- Токен в httpOnly secure cookie, не в localStorage
- Алгоритм зафиксирован на сервере,
algorithmsпередан явно - Access TTL — 5-15 минут, refresh с ротацией
- Секрет минимум 256 бит, хранится в vault/env, не в коде
- Есть механизм revocation (blacklist или версионирование)
- Payload минимальный: sub, role, iat, exp, aud, iss
- aud и iss проверяются при каждой верификации
Каждая из этих ошибок — реальный вектор атаки. Исправление занимает минуты, а последствия эксплуатации — недели разбора инцидента и потерю доверия пользователей.