JWT pitfalls: 7 типичных ошибок при работе с токенами в продакшене
JWT стал стандартом де-факто для аутентификации в SPA и микросервисах. Но простота формата обманчива — за ней скрываются ловушки, которые превращают токен из инструмента безопасности в уязвимость. Вот семь ошибок, которые встречаются в продакшене снова и снова.
1. Хранение токена в localStorage
Любой XSS на странице получает полный доступ к токену из localStorage:
// Плохо: любой инжектированный скрипт украдёт токен
localStorage.setItem('token', jwt);
// Хорошо: httpOnly cookie недоступна из JS
res.cookie('access_token', jwt, {
httpOnly: true,
secure: true,
sameSite: 'strict',
maxAge: 15 * 60 * 1000
});
httpOnly cookie не панацея — нужна защита от CSRF. Но она закрывает весь класс XSS-атак на кражу сессии.
2. Отсутствие валидации алгоритма (alg:none)
Атака «alg:none» — злоумышленник подменяет заголовок на {"alg": "none"} и отправляет токен без подписи:
// УЯЗВИМО: библиотека выбирает алгоритм из заголовка
const payload = jwt.verify(token, secret);
// БЕЗОПАСНО: явно указываем допустимые алгоритмы
const payload = jwt.verify(token, secret, {
algorithms: ['HS256']
});
Ещё опаснее — путаница RS256/HS256. Атакующий подписывает токен публичным ключом с alg: HS256, и сервер принимает подделку.
3. Слишком долгий TTL без refresh-ротации
Access token с TTL в 24 часа — окно, в течение которого украденный токен даёт полный доступ. Правильная схема:
// Access — 15 минут, refresh — одноразовый
const accessToken = jwt.sign(payload, secret, { expiresIn: '15m' });
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';
// Хорошо: минимум 256 бит энтропии для HS256
// Генерация: openssl rand -base64 32
const secret = process.env.JWT_SECRET; // 64 символа base64
Для продакшена рассмотрите RS256 с ротацией ключей через JWKS endpoint — позволяет менять ключи без инвалидации всех токенов.
5. Отсутствие revocation — logout не работает
JWT stateless — сервер не хранит состояние. «Выйти из системы» клиент не может: токен валиден до истечения TTL.
// Blacklist в Redis
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:
if (await redis.exists(`blacklist:${token}`)) {
return res.status(401).json({ error: 'Token revoked' });
}
Альтернатива: версионирование — tokenVersion в БД пользователя, при logout инкрементируется.
6. Раздутый payload
JWT в каждом запросе. Если payload содержит роли, пермишены, профиль — токен раздувается. HTTP-заголовки ограничены (8KB nginx), cookie — 4KB на домен.
// Плохо: всё в токене
const payload = { sub: userId, roles: [...], permissions: [...50 items], profile: {...} };
// Хорошо: минимум в токене
const payload = { sub: userId, role: 'admin' };
// Детальные пермишены — запрос к сервису или кэш
7. Игнорирование aud/iss claims
Без проверки aud и iss токен от сервиса A принимается сервисом B:
// Создание с указанием audience
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
- Payload минимальный: sub, role, iat, exp, aud, iss
- aud и iss проверяются при верификации
Каждая из этих ошибок — реальный вектор атаки. Исправление занимает минуты, а последствия эксплуатации — недели разбора инцидента.