JWT Best Practices: signing algorithms (RS256 over HS256 for multi-service), key rotation via kid header and JWKS, short-lived access + rotating refresh tokens, audience and exp/nbf validation, jti for revocation via Redis blocklist, alg-confusion attacks (alg=none, RS256->HS256 swap), and storage trade-offs (HttpOnly cookies vs localStorage XSS). 5 animated scenarios plus an embedded ADR comparing JWT vs opaque tokens with introspection.
JWT — это подписанный, не зашифрованный JSON, передаваемый между клиентом и сервисами. Любой может прочитать payload (это base64, не криптография), но только обладатель ключа подписи может изготовить валидный токен. Это даёт stateless-аутентификацию: сервису не нужно ходить в БД сессий — он проверяет подпись и доверяет claims.
Цена этой свободы — головная боль с отзывом, ротацией ключей и выбором алгоритма. JWT отлично подходит для коротких access-токенов в OIDC/OAuth и межсервисного общения, и плохо — для долгих пользовательских сессий, где нужен мгновенный logout. Этот READMEпоказывает defaults, на которые опирается RFC 8725 (JWT Best Current Practices), и атаки, которые ловит whitelist алгоритма.
«Сервер подписывает JSON, клиент носит его в каждом запросе. Сервис проверяет подпись по публичному ключу из JWKS и доверяет тому, что внутри — но только если до этого валидировал
alg,iss,aud,exp,nbf. Stateless по умолчанию, stateful — только если нужен revocation.»
Три кита, на которых стоит безопасный JWT-пайплайн:
RS256/ES256/EdDSA). Никогда не доверять alg из header без проверки.Всё остальное (revocation, JWKS-кеш, kid-ротация) — производные от этих трёх.
Пять групп, отражающих живую систему:
jti плюс опциональный /introspect для opaque-токенов.Связи описывают физический поток: клиент логинится в signer → получает JWT → ходит с ним в gateway → verifier берёт публичный ключ из JWKS-кеша → пробивает jti по blocklist → пропускает в service. Стрелки от attacker и xss-script — заранее проложенные дорожки для атак (forged token, exfil).
Issue + Verify (RS256). Happy path всего lifecycle. Клиент логинится, signer запрашивает приватный ключ из KMS (он никогда не покидает vault), подписывает токен с kid=key-2026-05, кладёт refresh в store. Клиент сохраняет access в HttpOnly cookie и идёт в API. Verifier парсит kid, тянет публичный ключ из JWKS-кеша (cache miss → /.well-known/jwks.json, потом TTL 1 час), и прогоняет весь чек-лист: alg по whitelist, iss, aud, exp, nbf, подпись. Только после этого запрос идёт в сервис. Один промах в этом списке — и токен пробивает любой защитный периметр.
Refresh rotation. Access-токен умирает через 15 минут — это фича, а не баг. Браузер меняет старый refresh R1 на новую пару (access, R2), и signer помечает R1 как USED. Если потом приходит запрос с R1 ещё раз (attacker украл и реплеит), refresh-store видит "уже использован" → kill the whole family: убиваем R1, R2, всех потомков, форсим re-login. Это reuse detection — стандартный приём OAuth 2.1, который превращает украденный refresh в одноразовый.
Revocation (jti blocklist). Пользователь жмёт logout. Signer добавляет jti=tk-7c2f в Redis с TTL = (exp - now). Атакующий уже успел украсть тот же токен — приносит его в gateway. Подпись валидна, exp не истёк, всё формально OK, но verifier пробивает jti по blocklist → HIT → 401. Это компромисс: мы пожертвовали чистым stateless ради возможности убить токен раньше его смерти. Запись из ADR в конце сценария формулирует выбор прямым текстом.
alg=none и RS256→HS256. Два классических CVE-class атаки на JWT-парсеры. Первая — alg: none (исторический default в Auth0, PyJWT, jsonwebtoken до 2015), вторая — подмена алгоритма с асимметричного на симметричный с публичным ключом в роли HMAC-секрета. Обе бьются одной и той же защитой: серверный whitelist алгоритма, не доверять header. RFC 8725 кодифицирует это как обязательное best practice.
XSS vs HttpOnly cookie. Сторона A: XSS-скрипт читает localStorage.getItem('jwt') и сливает токен на evil-URL. Сторона B: тот же XSS пытается прочитать HttpOnly cookie — JS не видит её вообще, document.cookie возвращает пусто. Цена решения: cookie автоматически шлётся с каждым запросом, открывая CSRF-вектор, который закрывается SameSite=Lax/Strict плюс CSRF-токеном для state-changing POST-ов. Это известная пара компромиссов, тогда как localStorage + XSS = game over без шансов.
| Выбор | Pro | Con | Когда |
|---|---|---|---|
| RS256 vs HS256 | Multi-service: только auth держит приватный ключ, остальные верифицируют публичным | Подпись ~256 байт vs ~32 для HS, чуть дороже CPU | HS256 — один сервис, один владелец секрета; RS256 — два и более потребителя токенов |
| HttpOnly cookie vs localStorage | XSS не читает HttpOnly | CSRF-вектор, нужен SameSite + CSRF-токен | Browser — всегда cookie; mobile/CLI — Keychain/Keystore/env |
| Short TTL + refresh vs длинный access | Blast radius ≤ TTL | Roundtrip за refresh каждые 15 мин | По умолчанию короткий; длинный access — только если нет refresh-инфраструктуры |
| JWT vs opaque + introspection | Stateless, один verify, без сетевого round-trip | Revocation требует Redis blocklist или per-user ver claim | JWT — высокий scale + ок с TTL-lag; opaque — банкинг/админка с мгновенным kill |
Blocklist в Redis vs ver-claim | Blocklist гранулярен (любой токен) | Каждый verify = поход в Redis | ver — для bump-all-at-once (смена пароля); blocklist — для отдельных токенов |
| JWKS cache 1h vs always-fetch | Не валим auth-server | Ротация ключа видна с задержкой до TTL | 1 час — практический баланс; держи старые kid ещё один TTL после ротации |
Главный ADR, который скрипт подсвечивает в сценарии revocation: выбор stateless-JWT — это решение принять ограничение по revocation в обмен на отсутствие network hop при верификации. Если "logout = выйти прямо сейчас" — требование контракта, либо опирайся на opaque+introspection, либо смирись с Redis blocklist и потерей чистого stateless.
Реальные инциденты, попавшие в публичные пост-мортемы:
alg: none accepted by default (2015–2018) в Auth0, Firebase, многих JS/Python либах — массовый патч-ран по экосистеме.alg: any без whitelist — почти любой confusion-attack тривиален.git grep secret находит за секунду.kid и без ротации — компрометация ключа = переподписать всё разом, простоя не избежать.exp отсутствует — токен живёт вечно; revocation невозможна.kid без sanitization — kid: ../../../etc/passwd ловит path-traversal в lookup; jku header с attacker-controlled URL ловит JWKS-injection.