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 не нужен.