JWT: ловушки и правильное применение

JWT удобен и моден, но именно из-за кажущейся простоты в нём допускают опасные ошибки — разберём их и научимся проверять токен правильно.

JWT (JSON Web Token) — компактный самодостаточный токен из трёх частей (заголовок, полезная нагрузка, подпись), закодированных Base64URL через точку; подпись позволяет проверить, что содержимое не подменили.

JWT часто используют как access- или id-токен: сервер подписывает набор утверждений (claims) — например, идентификатор пользователя и срок действия — и клиент носит этот токен с собой. Притягательность в том, что токен самодостаточен: проверяющей стороне не нужно ходить в базу за сессией, достаточно проверить подпись. Но та же самодостаточность создаёт ловушки: вся безопасность держится на правильной проверке подписи и полей, и любая поблажка превращает «защищённый токен» в подделываемую бумажку. Экспериментировать с JWT можно на своих сервисах; подделывать чужие токены — это ст. 272 УК РФ.

Зачем это знать защитнику

Ошибки в работе с JWT попадают в категории Identification and Authentication Failures и Cryptographic Failures OWASP Top 10. Разработчик, понимающий устройство токена, не отключит проверку подписи «чтобы заработало» и не положит в payload секреты. Защитник по этим признакам мгновенно находит критические дыры в ревью.

Из чего состоит JWT

Три части, разделённые точками: header.payload.signature. Заголовок описывает алгоритм подписи (alg), payload содержит claims (sub, exp, iss, aud и т.п.), подпись считается по заголовку и payload с секретом/ключом. Критически важно понимать: header и payload — это просто Base64URL, не шифрование. Их может прочитать кто угодно:

# Payload в JWT не зашифрован — это видно любому, у кого есть токен
import base64, json
payload_b64 = "eyJzdWIiOiJ1c2VyMTIzIiwicm9sZSI6InVzZXIifQ"
pad = "=" * (-len(payload_b64) % 4)
print(json.loads(base64.urlsafe_b64decode(payload_b64 + pad)))

Вывод:

{'sub': 'user123', 'role': 'user'}

Отсюда первое правило: не кладите в JWT секреты (пароли, ключи, чувствительные персональные данные) — payload фактически открыт. Подпись защищает от подмены, но не от чтения.

Ловушка 1: alg=none

Спецификация JWT допускает значение alg: "none" — «токен без подписи». Если проверяющая библиотека (или код) доверяет полю alg из самого токена и видит none, она может принять токен вообще без проверки подписи. Тогда атакующий берёт валидный токен, переписывает payload (например, повышает себе роль), ставит alg: none, убирает подпись — и сервер верит. Корень — доверие к подсказке из непроверенного источника.

# УЯЗВИМО: алгоритм берётся из самого токена; принимается и 'none'
claims = jwt.decode(token, key, algorithms=None)   # доверяем header.alg

# БЕЗОПАСНО: сервер сам жёстко задаёт ожидаемый алгоритм
claims = jwt.decode(token, key, algorithms=["HS256"])  # 'none' будет отвергнут

Родственная атака — подмена алгоритма RS256 → HS256: если сервер ждёт асимметричный RS256 (проверка публичным ключом), а злоумышленник присылает токен, помеченный HS256, плохо написанный код может использовать публичный ключ как секрет для HMAC — а публичный ключ известен. Поэтому ожидаемый алгоритм всегда задаёт сервер, а не токен.

Ловушка 2: слабый ключ

Для HMAC-подписи (HS256) безопасность равна стойкости секрета. Короткий или словарный секрет (secret, password123) перебирается офлайн: атакующий берёт перехваченный токен и подбирает ключ, на котором подпись сходится, после чего может подписывать любые токены. Защита — длинный криптослучайный секрет (десятки байт случайности), хранимый вне кода (переменная окружения, секрет-менеджер), и его ротация. Для распределённых систем удобнее асимметричные алгоритмы (RS256/ES256): приватным ключом подписывает только эмитент, а проверять подпись публичным ключом могут все, не зная секрета.

Ловушка 3: не проверяют подпись и срок

Две частые ошибки идут парой. Первая — не проверяют подпись вовсе: код декодирует payload и доверяет claims, не убедившись, что токен подписан валидным ключом (иногда это делают «ради скорости» или по незнанию). Тогда любой может сочинить payload. Вторая — не проверяют срок действия и контекст: игнорируют exp (токен бессрочен — украденный работает вечно), iss/aud (принимают токен, выпущенный для другого сервиса или другим эмитентом). Корректная проверка включает все эти поля:

# БЕЗОПАСНО: проверяем подпись, срок, издателя и аудиторию
claims = jwt.decode(
    token,
    key,
    algorithms=["RS256"],
    issuer="https://auth.example.com",   # ожидаемый iss
    audience="my-api",                    # ожидаемый aud
    options={"require": ["exp", "iat"]},  # обязательные поля, exp проверяется
)

Ловушка 4: где хранить токен и как отозвать

На клиенте JWT в localStorage доступен любому скрипту, поэтому XSS легко его украдёт. Хранение в httpOnly + Secure cookie закрывает доступ из JS (но требует защиты от CSRF — см. SameSite). Вторая проблема — отзыв: самодостаточный токен невозможно «выключить» до истечения exp, ведь сервер не сверяется с базой. Поэтому делают access-токены короткоживущими (минуты) и обновляют их через серверный refresh-токен, который можно отозвать; для немедленного отзыва ведут серверный denylist скомпрометированных токенов. Это компромисс: чем самодостаточнее токен, тем труднее его отозвать.

Когда JWT не нужен

JWT оправдан там, где нужна проверка без обращения к хранилищу: stateless-API, обмен между сервисами, передача удостоверения от провайдера (OIDC id_token). Но для обычной серверной веб-сессии «одного приложения» классическая серверная сессия с непредсказуемым ID в httpOnly-cookie часто проще и безопаснее: её легко отозвать, идентификатор не несёт данных, и большинства ловушек JWT просто нет. Не выбирайте JWT по моде — выбирайте по задаче.

Как это работает под капотом

Проверка JWT — это пересчёт подписи по header.payload ожидаемым алгоритмом и ключом и сравнение с присланной подписью; затем — проверка claims (срок, издатель, аудитория). Безопасность рушится, если хоть один шаг ослаблен: алгоритм взят из токена (alg confusion / none), ключ слаб (перебор), подпись не проверяется (доверие payload) или не сверяются exp/iss/aud (повтор и подмена контекста). То есть JWT безопасен ровно настолько, насколько строга проверка на сервере.

Как защититься

  • Алгоритм задаёт сервер. Явный allowlist (algorithms=["RS256"]); запрет none; защита от RS256→HS256 confusion.
  • Сильный ключ. Длинный криптослучайный секрет для HMAC вне кода, ротация; либо асимметричные RS256/ES256 с приватным ключом у эмитента.
  • Полная проверка claims. Подпись + exp (срок) + iss + aud. Никогда не доверяйте payload без проверки подписи.
  • Никаких секретов в payload — он лишь закодирован, а не зашифрован.
  • Хранение и отзыв. httpOnly+Secure cookie (с защитой от CSRF) вместо localStorage; короткий exp + refresh-токен; denylist для немедленного отзыва.
  • Выбор по задаче. Для одностраничной серверной сессии часто лучше обычная серверная сессия; JWT — для stateless/межсервисных сценариев.
  • Обнаружение. Логируйте отвергнутые токены (плохая подпись, alg=none, истёкший/чужой aud) — всплеск таких сигналов означает попытку подделки.

Юридическое напоминание: подделывать и перебирать ключи можно только для своих токенов в лаборатории; атака на чужие токены наказуема (ст. 272/273 УК РФ).

Итоги

  • JWT самодостаточен, но безопасен лишь при строгой проверке подписи и claims на сервере.
  • alg=none и RS256→HS256 confusion закрываются явным заданием ожидаемого алгоритма сервером.
  • Ключ HMAC должен быть длинным, случайным и вне кода; иначе подпись перебирается офлайн.
  • Всегда проверяйте подпись, exp, iss, aud; payload не зашифрован — секретов в него не кладут.
  • Храните токен в httpOnly-cookie, держите короткий срок жизни и механизм отзыва; а где задача — обычная сессия, JWT не нужен.
Проверьте себя
1. Почему атака с alg=none опасна и как от неё защититься?
Aalg=none шифрует payload слишком слабо; защита — длиннее ключ
BЕсли сервер доверяет полю alg из токена, он может принять токен без проверки подписи и поверить подменённому payload; защита — сервер сам жёстко задаёт ожидаемый алгоритм (allowlist)
Calg=none ускоряет проверку, поэтому его надо включать всегда
DЭто не уязвимость, а штатный режим для публичных токенов
2. Что можно класть в payload JWT?
AЛюбые секреты — payload надёжно зашифрован
BПароль пользователя, ведь токен подписан
CТолько несекретные утверждения (id пользователя, роль, срок): payload лишь закодирован Base64URL и читается любым, у кого есть токен
DПриватный ключ подписи для удобства проверки
3. Какой набор проверок JWT на сервере корректен?
AДекодировать payload и сразу доверять claims — так быстрее
BПроверить только подпись, срок действия можно игнорировать
CПроверить подпись ожидаемым алгоритмом и ключом, а также exp (срок), iss (издатель) и aud (аудиторию)
DПроверить только, что токен состоит из трёх частей через точку