lenec ru

← все посты

Pino vs Winston в 2026: бенчмарки, настройка и выбор логгера для Node.js

10K

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 проверяются при каждой верификации

Каждая из этих ошибок — реальный вектор атаки. Исправление занимает минуты, а последствия эксплуатации — недели разбора инцидента и потерю доверия пользователей.

Комментарии 0

  • Будьте первым, кто оставит комментарий.

Войдите, чтобы оставить комментарий.