JWT и токены: устройство, подпись, срок жизни, отзыв и когда JWT не нужен
Токен — это предъявляемое доказательство того, что кто-то уже прошёл проверку. Пользователь ввёл пароль и второй фактор один раз (как это делается — в статье Аутентификация), после чего система выдала предмет, который можно показывать при каждом следующем запросе вместо повторного ввода. Дальше весь вопрос в том, что именно этот предмет доказывает, кому и как долго.
JWT (JSON Web Token) — самый распространённый формат такого предмета, и одновременно самый неправильно применяемый. Его выбирают, потому что «так делают все» и «это stateless», а потом обнаруживают, что выйти из системы по-настоящему нельзя, ключ не ротируется, а в payload лежит номер телефона в открытом виде. Эта статья разбирает формат до байтов, перечисляет обязательные проверки, показывает уязвимый и исправленный код — и отдельно, честно, разбирает случаи, когда JWT не нужен вовсе.
Материал написан с позиции защищающейся стороны. Уязвимый код здесь приведён, чтобы вы узнали его в своём репозитории и починили, а не чтобы что-то ломать. Любая проверка описанных дефектов допустима только на системах, которыми вы владеете, или при наличии письменного разрешения владельца с зафиксированным объёмом и сроками работ. Проверка чужого сервиса без такого разрешения — не тестирование, а правонарушение.
Базовые классификаторы, к которым мы будем возвращаться: CWE-347 Improper Verification of Cryptographic Signature, CWE-345 Insufficient Verification of Data Authenticity, CWE-613 Insufficient Session Expiration. В OWASP Top 10 тема распределена между A01, A02 и A07 — разбор категорий в статье OWASP Top 10.
Токен как понятие: два принципиально разных предмета
Прежде чем говорить о JWT, надо развести два вида токенов, которые внешне выглядят одинаково — строка в заголовке Authorization.
Reference-токен (opaque, ссылочный). Строка не значит ничего; это ключ к записи в хранилище сервера. Чтобы понять, кто это и что ему можно, сервер обязан сходить в базу. Классическая серверная сессия — именно такой токен.
Self-contained токен (самодостаточный). Строка сама содержит утверждения о субъекте и криптографическую подпись эмитента. Чтобы понять, кто это, достаточно проверить подпись — обращения к хранилищу не нужно. JWT в подписанном варианте — именно такой.
| Свойство | Reference (opaque) | Self-contained (JWT) |
|---|---|---|
| Что нужно для проверки | запрос в хранилище | открытый ключ эмитента |
| Латентность проверки | сеть/диск на каждый запрос | микросекунды, локально |
| Отзыв | мгновенный, удалением записи | принципиально отложенный |
| Утечка данных при краже токена | ничего не раскрывает | раскрывает весь payload |
| Размер | 20–40 байт | 400–1500 байт и растёт |
| Работает без общего стораджа | нет | да |
| Изменение прав | видно сразу | видно после переиздания |
Это симметричный размен: JWT покупает независимость проверки ценой отзыва. Все остальные проблемы производны от этого обмена. Если вы не можете назвать, зачем вам независимость проверки, — покупать нечего.
Обе разновидности предъявляются по семантике bearer, описанной в RFC 6750: «кто предъявил — тот и владелец». Bearer-токен не привязан к клиенту, поэтому его кража эквивалентна краже учётной записи на весь срок жизни токена. Способы это ограничить (DPoP, mTLS) разбираются ниже.
Семейство JOSE: кто за что отвечает
JWT — не отдельный стандарт, а профиль над набором спецификаций JOSE (JSON Object Signing and Encryption). Путаница в терминах — источник половины ошибок, поэтому разложим.
- RFC 7515 JWS — подпись/MAC; это то, что почти всегда имеют в виду под «JWT». RFC 7516 JWE — шифрование: пять частей вместо трёх, payload не читается посторонним.
- RFC 7517 JWK — представление ключа в JSON и набор ключей JWKS. RFC 7518 JWA — реестр алгоритмов:
HS256,RS256,ES256,PS256,EdDSAи печально известныйnone. - RFC 7519 JWT — собственно профиль: зарегистрированные claim и правила их обработки. RFC 9068 — как должен выглядеть access-токен OAuth в виде JWT.
- RFC 8725 JWT BCP — обязательный к прочтению документ: он перечисляет ровно те ошибки, которые встречаются в проде.
Практическое следствие: подписанный JWT не конфиденциален. «Зашифровать» его можно только через JWE, и это отдельное решение с отдельным управлением ключами. По умолчанию считайте payload публичным.
Устройство: три части, две из которых подписаны
Компактная сериализация JWS — это base64url(header) . base64url(payload) . base64url(signature). Кодирование — base64url без выравнивающих = (RFC 4648, §5), поэтому строка безопасна для URL и заголовков.
Ключевая деталь, которую часто упускают: подпись считается над ASCII-байтами первых двух сегментов ровно в том виде, в каком они пришли. Не над распарсенным JSON, не над нормализованным объектом. Поэтому любая попытка «сначала распарсить, потом проверить» ломает семантику: два разных байтовых представления одного JSON дадут разные подписи, а библиотека, которая пересобирает строку сама, рано или поздно получит несовпадение.
Зарегистрированные claim из RFC 7519 — минимальный словарь, который надо знать наизусть:
| Claim | Смысл | Кто проверяет | Обязателен? |
|---|---|---|---|
iss |
эмитент | ресурс-сервер сверяет со своим списком | да |
sub |
субъект (идентификатор пользователя) | приложение | да |
aud |
адресат: для кого выписан | ресурс-сервер сверяет со своим именем | да |
exp |
время истечения (сек. с эпохи) | верификатор | да |
nbf |
не действителен раньше | верификатор | по ситуации |
iat |
время выпуска | верификатор (санитарная проверка) | да |
jti |
уникальный идентификатор токена | верификатор при денилисте/anti-replay | да для access |
Плюс из OpenID Connect — sid (идентификатор сессии, нужен для back-channel logout), azp, auth_time, acr/amr. Профиль OAuth-access-токена (RFC 9068) дополнительно требует typ: "at+jwt" в заголовке и client_id. Как эти claim появляются в потоке авторизации — в статье OAuth 2.0 и OpenID Connect.
Отдельно про typ. Это не украшение: если один и тот же ключ подписывает access-токены, id-токены и logout-токены, то без разделения по typ (и по aud) токен одного назначения может быть принят там, где ждут другой. Это разновидность cross-JWT confusion, прямо описанная в RFC 8725, §3.11.
Алгоритмы подписи: что выбирать и почему
Развилка простая. Проверяет подпись только тот же сервис, который её поставил, — годится симметричный HS256. Проверяют другие сервисы — только асимметричная схема, потому что при HMAC любой, кто умеет проверять, умеет и выпускать. Из асимметричных по умолчанию берите ES256 или EdDSA: подпись 64 байта против 256 у RSA, проверка быстрее, токен легче.
| Алгоритм | Тип | Размер подписи | Когда уместен | Оговорки |
|---|---|---|---|---|
HS256 |
HMAC-SHA-256 | 32 байта | эмитент и верификатор — один контур | ключ известен всем проверяющим, значит все могут выпускать токены |
RS256 |
RSA PKCS#1 v1.5 | 256 байт | максимальная совместимость | самая медленная проверка и самый жирный токен |
PS256 |
RSA-PSS | 256 байт | новые RSA-внедрения | предпочтительнее RS256 по конструкции |
ES256 |
ECDSA P-256 | 64 байта | дефолт для распределённой проверки | требует качественного nonce; берите проверенную библиотеку |
EdDSA |
Ed25519 | 64 байта | лучший выбор, если поддерживается | детерминированная подпись, нет проблемы nonce |
none |
— | 0 | никогда | в верификаторе должен быть недостижим |
Главное правило: алгоритм — свойство ключа и конфигурации, а не токена. Заголовок alg — это подсказка от предъявителя, то есть недоверенный ввод. Отсюда растут два классических дефекта, разберём их по общей схеме: как выглядит уязвимый код → почему это работает → как чинить → как проверить.
Дефект 1: доверие к полю alg, включая none
Как выглядит уязвимый код.
import jwt # PyJWT
def get_user(token: str) -> dict:
# Алгоритм не задан: библиотека возьмёт его из заголовка токена
claims = jwt.decode(token, PUBLIC_KEY, options={"verify_signature": True})
return claims
Аналог на Go с ручным keyFunc, который игнорирует метод подписи:
// УЯЗВИМО: keyFunc не смотрит на token.Method
tok, err := jwt.Parse(raw, func(t *jwt.Token) (interface{}, error) {
return publicKey, nil // отдаём ключ, каким бы ни был alg в заголовке
})
Почему это работает. RFC 7515 допускает «unsecured JWS» с alg: "none" и пустой подписью (Приложение A.5) — конструкция, предназначенная для случаев, когда целостность обеспечена другим слоем. Библиотека, выбирающая алгоритм по заголовку, честно исполняет спецификацию: получив none, она пропускает криптопроверку и возвращает claim. Дальше содержимое payload — это то, что написал предъявитель. Класс — CWE-347. Публичный разбор этого семейства дефектов в библиотеках сделал Тим Маклин ещё в 2015 году: Critical vulnerabilities in JSON Web Token libraries.
Как чинить. Список допустимых алгоритмов задаётся в коде верификатора и содержит ровно один элемент (в момент миграции — два).
claims = jwt.decode(
token,
key=public_key,
algorithms=["ES256"], # allow-list, а не «что придёт»
audience="billing-api",
issuer="https://auth.example.com",
leeway=60,
options={
"require": ["exp", "iat", "nbf", "iss", "aud", "sub", "jti"],
"verify_exp": True, "verify_nbf": True,
"verify_iss": True, "verify_aud": True,
},
)
tok, err := jwt.ParseWithClaims(raw, &Claims{}, keyFunc,
jwt.WithValidMethods([]string{"ES256"}), // allow-list алгоритмов
jwt.WithIssuer("https://auth.example.com"),
jwt.WithAudience("billing-api"),
jwt.WithExpirationRequired(),
jwt.WithLeeway(60*time.Second),
)
Как проверить, что починено. Регрессионный тест в своём репозитории: собрать три токена — с alg: none и пустой подписью, с валидным заголовком и испорченным последним байтом подписи, с валидной подписью, но обрезанным payload — и убедиться, что верификатор на всех трёх возвращает 401 и не логирует содержимое claim. Тест живёт вечно и ловит регресс при смене библиотеки. Про место таких тестов в конвейере — Тесты в CI.
Дефект 2: подмена асимметричного алгоритма симметричным
Как выглядит уязвимый код. Тот же jwt.decode(token, public_key) без algorithms, но акцент другой: сервис объявляет, что работает по RS256, и передаёт в верификатор публичный ключ.
Почему это работает. Публичный ключ — не секрет: он лежит в JWKS по известному адресу. Если верификатор берёт alg из токена, то токен с alg: "HS256" заставит библиотеку интерпретировать переданный материал ключа как секрет HMAC. А этот материал общедоступен. В результате подпись, вычисленная над публичным ключом, проходит проверку. Формально это CWE-347 плюс CWE-757 Selection of Less-Secure Algorithm During Negotiation; RFC 8725 называет это «algorithm confusion» и требует allow-list в §3.1.
Как чинить. То же, что в дефекте 1, плюс два усиления. Первое: верификатор получает типизированный объект ключа (EllipticCurvePublicKey, rsa.PublicKey), а не строку/байты — современные библиотеки тогда откажутся использовать его как HMAC-секрет (PyJWT ≥ 2.0 бросает InvalidKeyError). Второе: ключи для HMAC и для асимметричной подписи физически разные и живут в разных местах — HMAC-секрет только в секрет-менеджере эмитента, публичный ключ в JWKS (см. Секреты и ключи).
Как проверить. Тест: взять свой же публичный ключ из JWKS, собрать токен с alg: HS256, подписав им как секретом, отправить своему сервису на тестовом стенде — ожидание 401. Дополнительно — грепом по репозиторию убедиться, что нет ни одного вызова декодирования без явного списка алгоритмов; это простое правило для SAST (см. Безопасная разработка).
Дефект 3: доверие к kid, jku, x5u
Как выглядит уязвимый код.
header = jwt.get_unverified_header(token)
# УЯЗВИМО: путь строится из недоверенного значения
with open(f"/etc/keys/{header['kid']}.pem", "rb") as f:
key = f.read()
# УЯЗВИМО: адрес набора ключей берётся из самого токена
jwks = requests.get(header["jku"]).json()
Почему это работает. kid, jku, x5u — поля заголовка, то есть ввод. kid, попавший в путь файла, даёт обход каталога (../../proc/self/environ), в SQL-запрос — инъекцию (см. Инъекции), в команду — исполнение. jku/x5u заставляют сервер сходить по указанному адресу и взять оттуда ключ: это одновременно SSRF (CWE-918) и полный обход подписи, потому что ключ выбирает предъявитель.
Как чинить. kid — непрозрачная строка для точного соответствия в словаре, загруженном из конфигурации, а не из токена; никаких конкатенаций с путями, URL и SQL. Адрес JWKS берётся из конфигурации сервиса либо из метаданных эмитента (RFC 8414), скачанных по фиксированному URL; jku и x5u из токена игнорируются полностью — RFC 8725, §3.5 прямо это рекомендует. Если kid отсутствует, допустимо перебрать ключи текущего набора: дороже, но безопасно.
# kid используется ТОЛЬКО как ключ поиска в заранее загруженном наборе
key = JWKS_CACHE.get(header.get("kid"))
if key is None:
raise Unauthorized("unknown kid")
Как проверить. Тесты с kid, содержащим ../, нулевой байт, кавычку и очень длинную строку: ожидание — 401 и отсутствие обращений к файловой системе. Отдельная проверка: исходящие соединения верификатора ограничены allow-list хостов на уровне сети, поэтому попытка сходить на посторонний адрес не удастся даже при ошибке в коде — это защита в глубину.
Дефект 4: подпись проверена, а iss и aud — нет
Как выглядит уязвимый код.
claims = jwt.decode(token, key=key, algorithms=["ES256"]) # aud/iss не заданы
user_id = claims["sub"]
Почему это работает. Валидная подпись доказывает только одно: токен выпущен обладателем ключа и не изменён. Она не доказывает, что токен предназначен вашему сервису. Три реальных сценария: один эмитент обслуживает десяток внутренних API, и токен для сервиса «отчёты» принимается сервисом «платежи»; сервис принимает токены нескольких эмитентов (multi-tenant SaaS, федерация), и токен арендатора A проходит в контексте арендатора B, потому что проверяется лишь «подпись валидна хоть чьим-нибудь ключом из набора»; id-токен OIDC, предназначенный клиенту, принимается как access-токен API — та самая cross-JWT confusion. Класс — CWE-863 Incorrect Authorization.
Как чинить. Проверка iss выполняется до выбора ключа: сначала определяем эмитента, затем берём набор ключей именно этого эмитента, затем проверяем подпись, затем сверяем aud со своим именем и typ с ожидаемым.
ISSUERS = { # из конфигурации, не из токена
"https://auth.example.com": JwksClient("https://auth.example.com/jwks"),
}
AUDIENCE = "billing-api"
def verify(token: str) -> Claims:
header = jwt.get_unverified_header(token)
if header.get("typ") != "at+jwt": # RFC 9068
raise Unauthorized("wrong token type")
unverified = jwt.decode(token, options={"verify_signature": False})
client = ISSUERS.get(unverified.get("iss")) # только точное соответствие
if client is None:
raise Unauthorized("unknown issuer")
key = client.key_for(header.get("kid"))
claims = jwt.decode(
token, key=key, algorithms=["ES256"],
audience=AUDIENCE, issuer=unverified["iss"], leeway=60,
options={"require": ["exp", "iat", "iss", "aud", "sub", "jti"]},
)
return Claims(**claims)
Здесь jwt.decode(..., verify_signature=False) используется исключительно для маршрутизации к нужному ключу, а результат нигде не применяется как факт. Это допустимо, но требует комментария в коде, чтобы следующий разработчик не «оптимизировал» его в бизнес-логику.
Как проверить. Тесты: токен с чужим aud → 401; токен с iss, отличающимся регистром или завершающим слешем, → 401 (сравнение строгое, без нормализации); id-токен, отправленный как access, → 401. Для multi-tenant добавьте тест «токен арендатора A против ресурса арендатора B» — он же нужен для темы Авторизация.
Дефект 5: слабый или захардкоженный HMAC-секрет
Как выглядит уязвимый код.
SECRET = "secret" # или "changeme", или имя проекта
token = jwt.encode(claims, SECRET, algorithm="HS256")
Почему это работает. Проверка подписи HMAC не требует обращения к серверу: имея один валидный токен, можно офлайн перебирать кандидаты в секрет, пока подпись не сойдётся. Скорость перебора для HMAC-SHA-256 — миллиарды кандидатов в секунду на обычном GPU, поэтому словарный секрет вскрывается за минуты. Найденный секрет означает возможность выпускать любые токены. Классы — CWE-798 и CWE-326.
Как чинить. RFC 7518, §3.2 требует ключ не короче размера выхода хеш-функции: 256 бит для HS256.
import secrets
key = secrets.token_bytes(32) # 256 бит из CSPRNG, не из пароля
Секрет никогда не лежит в репозитории, в образе контейнера или в переменной окружения, доступной всем подам подряд; он приходит из секрет-менеджера и ротируется — подробно в статье Секреты и ключи. Если верификаторов больше одного сервиса — симметричная схема неуместна в принципе: каждый, кто может проверить, может и выпустить. Переходите на ES256/EdDSA.
Как проверить. Тест конфигурации, падающий при старте, если ключ короче 32 байт, совпадает со значением по умолчанию или входит в чёрный список известных «демо-секретов». Плюс секрет-сканер в CI (gitleaks, trufflehog) на всю историю репозитория — см. Безопасность цепочки поставок.
Дефект 6: decode вместо verify и чтение claim до проверки
Как выглядит уязвимый код.
// Фронтенд достал роль из токена и включил админскую панель
const role = JSON.parse(atob(token.split('.')[1])).role;
if (role === 'admin') showAdminPanel();
# Бэкенд логирует и метрикует по claim до проверки подписи
claims = jwt.decode(token, options={"verify_signature": False})
metrics.inc("requests", tenant=claims["tenant"]) # атакующий управляет меткой
Почему это работает. Декодирование base64url доступно любому. На фронтенде это не уязвимость сама по себе (панель — это UI, реальные запросы всё равно проверит сервер), но становится ею, как только сервер начинает доверять тому же принципу. На бэкенде чтение непроверенных claim ведёт к загрязнению метрик и логов, к инъекциям в системы агрегации и к «ленивой» логике, которая однажды примет решение по непроверенным данным.
Как чинить. Единственная точка входа — функция verify(), возвращающая типизированный объект Claims. Сырые claim не покидают эту функцию. В middleware в контекст запроса кладётся результат verify(), а не токен. Всё, что до проверки подписи, — только маршрутизация к ключу.
@dataclass(frozen=True)
class Claims:
sub: str
scope: frozenset[str]
tenant: str
jti: str
exp: int
Как проверить. Архитектурный тест: грепом или обходом AST убедиться, что verify_signature: False / decodeJwt / ParseUnverified встречаются ровно в одном модуле — в верификаторе, — и нигде больше. Такое правило легко оформить как линт и повесить на pre-commit.
Дефект 7: чувствительные данные в payload
Как выглядит уязвимый код.
{
"sub": "u-42",
"email": "ivanov@example.com",
"phone": "+7 900 000-00-00",
"passport": "4509 123456",
"internal_db_id": 10241,
"permissions": ["billing.write", "users.delete", "…ещё 80 строк"]
}
Почему это работает. Payload не зашифрован. Токен попадает в логи прокси, в Referer при переходе по внешней ссылке, в историю браузера при передаче в query-параметре, в отчёты об ошибках, в аналитику фронтенда. Каждое из этих мест — новое хранилище персональных данных, о котором никто не знает. Класс — CWE-522 и CWE-532 Insertion of Sensitive Information into Log File.
Вторая проблема — размер. Список из 80 прав раздувает токен до 4–8 КБ, и он начинает упираться в лимиты заголовков (large_client_header_buffers в nginx, 8 КБ по умолчанию в большинстве серверов), а также добавляется к каждому запросу, включая статику, если токен лежит в cookie.
Как чинить. В токене — только идентификаторы и грубые области доступа: sub, scope, tenant, короткие коды ролей; всё остальное сервис достаёт по sub из своей базы. Персональные данные в claim не кладутся вообще — инженерные практики минимизации разобраны в статье Приватность и соответствие. Токен передаётся только в заголовке Authorization или в cookie с флагами, никогда в query-параметре URL. Если конфиденциальность payload действительно нужна (токен проходит через недоверенного посредника) — это JWE, а не «мы же по HTTPS».
Как проверить. Тест на бюджет размера: собранный для «самого нагруженного» пользователя токен не превышает, скажем, 1 КБ, иначе CI падает. Второй тест — сканер логов на шаблон eyJ (начало base64url от {"): ни одна строка лога не должна содержать целый JWT. Маскирование Authorization в логгере проверяется отдельным тестом.
Эталонный верификатор с JWKS-кешем
Соберём всё вместе. Ниже — рабочий каркас; вариант на Go отличается только именами опций (jwt.WithValidMethods, WithIssuer, WithAudience, WithExpirationRequired, WithLeeway), смысл тот же.
import time
import jwt
from jwt import PyJWKClient
ISSUER = "https://auth.example.com"
AUDIENCE = "billing-api"
ALGS = ["ES256"]
# lifespan — время жизни кеша JWKS; адрес зафиксирован в конфигурации
_jwks = PyJWKClient(f"{ISSUER}/.well-known/jwks.json", cache_keys=True, lifespan=300)
def verify(token: str) -> dict:
header = jwt.get_unverified_header(token)
if header.get("typ") != "at+jwt":
raise Unauthorized("wrong typ")
if header.get("alg") not in ALGS: # ранний отказ, дешевле
raise Unauthorized("bad alg")
signing_key = _jwks.get_signing_key_from_jwt(token) # kid → ключ из своего JWKS
claims = jwt.decode(
token,
signing_key.key,
algorithms=ALGS,
audience=AUDIENCE,
issuer=ISSUER,
leeway=60,
options={"require": ["exp", "iat", "nbf", "iss", "aud", "sub", "jti"]},
)
# Санитарная проверка: токен из будущего или с абсурдным сроком — отказ
now = time.time()
if claims["iat"] > now + 60 or claims["exp"] - claims["iat"] > 3600:
raise Unauthorized("suspicious lifetime")
if is_revoked(claims): # см. раздел про отзыв ниже
raise Unauthorized("revoked")
return claims
import { createRemoteJWKSet, jwtVerify } from 'jose';
// URL фиксирован в конфигурации; библиотека сама кеширует и обновляет набор
const JWKS = createRemoteJWKSet(new URL(`${ISSUER}/.well-known/jwks.json`), {
cooldownDuration: 30_000, // защита от шторма запросов при неизвестном kid
cacheMaxAge: 600_000,
});
export async function verify(token: string) {
const { payload } = await jwtVerify(token, JWKS, {
issuer: ISSUER,
audience: AUDIENCE,
algorithms: ['ES256'],
typ: 'at+jwt',
clockTolerance: 60,
requiredClaims: ['exp', 'iat', 'iss', 'aud', 'sub', 'jti'],
});
return payload;
}
Три эксплуатационные детали, которые обычно всплывают в проде. Кеш JWKS обязателен, иначе каждый запрос порождает исходящий HTTP-вызов, и эмитент становится единой точкой отказа; разумный TTL — 5–15 минут. Неизвестный kid — не повод немедленно идти за JWKS: иначе поток мусорных токенов со случайными kid превращается в усилитель нагрузки на эмитента, поэтому набор обновляется не чаще раза в 30–60 секунд (cooldown), остальное отклоняется. Отказ верификатора должен быть fail-closed: если JWKS недоступен, а кеш протух — 503, а не «пропустим на всякий случай»; как это уживается с деградацией, разбирает статья Паттерны отказоустойчивости.
Ротация ключей: JWKS, kid и перекрытие
Ключ подписи компрометируется, устаревает или просто должен меняться по регламенту (NIST SP 800-57 Part 1 описывает криптопериоды). Ротация без простоя строится на перекрытии: новый ключ появляется в JWKS до того, как им начнут подписывать, а старый исчезает после того, как истекут все выданные им токены.
подписи ещё нет Published --> Active: истёк TTL кеша JWKS у всех верификаторов,
переключаем подпись Active --> Retiring: подписывать начал следующий ключ,
старый остаётся в JWKS Retiring --> Removed: прошло max(TTL access-токена) + запас Removed --> [*] Active --> Compromised: инцидент Compromised --> Removed: немедленное удаление из JWKS
+ массовый отзыв сессий note right of Published Минимальная задержка перед Active = TTL кеша JWKS + запас на сетевые сбои end note note right of Retiring Удалить раньше = отказы для живых токенов Не удалить = ключ живёт вечно end note
Модель хранения ключей и связанных сущностей:
Обратите внимание на private_ref: приватный ключ не хранится в прикладной БД. Он живёт в KMS/HSM, а приложение вызывает операцию подписи — так компрометация дампа базы не даёт возможности выпускать токены.
Срок жизни: почему «поставим сутки» — плохая идея
Схема выдачи почти всегда двухуровневая: короткий access-токен для запросов к API и длинный refresh-токен, который обменивается на новый access.
не отправляется на ресурс-сервер C->>API: GET /invoices, Authorization Bearer access API->>K: JWKS по kid (из кеша, раз в 5-15 минут) K-->>API: публичный ключ API-->>C: 200 OK Note over C,API: проходит 10 минут C->>API: GET /invoices с истёкшим access API-->>C: 401 с WWW-Authenticate invalid_token C->>AS: POST /token grant_type=refresh_token AS-->>C: новый access + НОВЫЙ refresh (ротация) C->>API: повтор исходного запроса API-->>C: 200 OK
Ориентиры по TTL — не догма, а отправная точка для разговора о рисках:
| Токен | Типичный TTL | От чего зависит |
|---|---|---|
| Access (JWT) | 5–15 минут | ширина окна неотозванного доступа, которое вы готовы терпеть |
| Refresh, веб-сессия | часы–дни, sliding | чувствительность данных, наличие ротации |
| Refresh, мобильное приложение | недели–месяцы с абсолютным потолком | UX против риска украденного устройства |
| Id-токен OIDC | минуты | он нужен только в момент логина |
| Токен сброса пароля | ≤ 15 минут, одноразовый | см. Аутентификация |
| Сервис-сервис (client credentials) | 5–60 минут | частота обновления прав |
Про часы. exp, nbf, iat — абсолютные метки в секундах от эпохи, а часы эмитента и верификатора расходятся. Без допуска вы получите «плавающие» 401 при первом же дрейфе NTP; с допуском в 5 минут вы на эти же 5 минут расширите окно приёма истёкших токенов. Разумный компромисс — 30–120 секунд плюс обязательный NTP на всех узлах. Почему абсолютное время в распределённой системе — ненадёжная опора, подробно разбирается в статье Время и часы.
Про iat как признак свежести. Соблазн «если iat старше N — требовать повторный вход» разбивается о то, что iat контролирует эмитент, а не ресурс. Для step-up-аутентификации существует auth_time из OIDC и параметр max_age; используйте их, а не самодельную арифметику по iat.
Refresh-токены: ротация и обнаружение повторного использования
Refresh-токен — самый ценный предмет в системе: он конвертируется в доступ месяцами. Поэтому он всегда ссылочный (opaque, случайные 256 бит из CSPRNG), хранится в базе только в виде хеша и ротируется при каждом использовании. RFC 9700 (BCP по безопасности OAuth 2.0) требует либо sender-constrained refresh, либо ротацию с обнаружением повторного использования.
выдана новая пара, старый помечен used Rotated --> [*]: нормальное завершение цепочки Active --> Expired: наступил expires_at Active --> Revoked: выход, смена пароля, действие администратора Rotated --> ReuseDetected: предъявлен ПОВТОРНО
значит копия у кого-то ещё ReuseDetected --> FamilyRevoked: гасим всё семейство,
уведомляем пользователя, пишем инцидент FamilyRevoked --> [*] Expired --> [*] Revoked --> [*] note right of ReuseDetected Повторное использование не различает, кто легитимен: жертва или похититель. Поэтому отзывается вся цепочка. end note
Ключевой момент — атомарность. Два параллельных обмена одним refresh (обычная ситуация при гонке вкладок или ретрае) не должны приводить ни к выдаче двух валидных цепочек, ни к ложному срабатыванию детектора.
-- Атомарная ротация: пометить использованным ровно один раз
UPDATE refresh_tokens
SET used_at = now(), replaced_by = $2
WHERE token_hash = $1
AND used_at IS NULL
AND revoked_at IS NULL
AND expires_at > now()
RETURNING family_id, user_id;
Если запрос вернул строку — обмен легитимен. Если ноль строк, разбираемся почему:
-- Токен существует, но уже использован → признак компрометации
SELECT family_id FROM refresh_tokens WHERE token_hash = $1 AND used_at IS NOT NULL;
-- Гасим всё семейство одним запросом
UPDATE refresh_tokens
SET revoked_at = now()
WHERE family_id = $1 AND revoked_at IS NULL;
Практические оговорки. Окно благодати: мобильные клиенты теряют ответ из-за обрыва сети и честно ретраят, а жёсткий детектор превратит это в постоянные разлогины — поэтому повторное предъявление в пределах 5–30 секунд возвращает тот же результат идемпотентно, из кеша ответа, а за пределами окна считается компрометацией (механика — в статье Идемпотентность и доставка). Хранить только хеш: SHA-256 достаточно, токен уже имеет полную энтропию, медленный KDF здесь не нужен в отличие от паролей; поиск идёт по индексу на хеше, а не перебором со сравнением. Абсолютный потолок поверх скользящего окна: даже активная сессия обязана однажды закончиться полным входом.
Отзыв: главная слабость stateless-схемы
Вот честная формулировка проблемы: подписанный токен нельзя отозвать, потому что для проверки подписи не нужен сервер. Всё, что называется «отзывом JWT», — это разные способы вернуть в схему кусочек состояния. Вопрос лишь в том, какой кусочек и какой ценой.
в 5-15 минут?"} B -- "приемлема" --> C["Только короткий TTL access.
Состояния ноль, latency ноль"] B -- "неприемлема" --> D{"Что отзываем?"} D -- "конкретный токен" --> E["Денилист по jti
Redis, TTL записи = до exp"] D -- "все токены пользователя" --> F["Эпоха: users.token_epoch
в claim, сверка с кешем"] D -- "одну сессию из многих" --> G["sid в claim +
back-channel logout OIDC"] D -- "любой токен, всегда" --> H["Introspection RFC 7662
или opaque-токены"] E --> I["+1 обращение в Redis на запрос"] F --> I G --> I H --> J["+1 сетевой вызов к эмитенту.
Это уже не stateless"] C --> K["Refresh отзывается всегда:
он ссылочный и лежит в БД"] I --> K J --> K
Разберём варианты по существу.
1. Короткий TTL и ничего больше. Отзыв = «дождаться истечения». Работает, если 10 минут лишнего доступа не критичны. Обязательное дополнение: refresh-токен отзывается мгновенно (он ссылочный), поэтому пользователь теряет доступ навсегда через ≤ TTL. Это дефолт для большинства продуктовых систем.
2. Денилист по jti. В Redis кладём jti отозванных токенов с TTL, равным остатку до exp. Список ограничен по размеру: он хранит только то, что ещё не истекло. Проверка — один EXISTS. Стоимость: сетевой вызов на каждый запрос и вопрос «что делать, если Redis недоступен» (для админских операций — fail-closed, для массового чтения — fail-open с алертом). Поведение Redis под нагрузкой и при отказах — в статье Redis.
3. Эпоха токенов (token version). В claim кладётся epoch, в БД у пользователя — счётчик. Смена пароля, выход со всех устройств, изменение роли инкрементируют счётчик, и все ранее выданные токены немедленно перестают подходить.
def is_revoked(claims: dict) -> bool:
# Кеш на 5-10 секунд убирает большую часть обращений к БД
current_epoch = cache.get_or_load(
f"epoch:{claims['sub']}", ttl=10, loader=load_epoch
)
if claims.get("epoch") != current_epoch: # эпоха сдвинулась → токен мёртв
return True
return denylist.exists(claims["jti"]) # точечный отзыв одного токена
Плюс схемы — одна короткая проверка отзывает сразу всё. Минус — не позволяет погасить одну сессию из пяти; для этого нужен sid.
4. Back-channel logout (OIDC). Сервер авторизации сам рассылает подписанные logout-токены зарегистрированным клиентам; клиент по sid гасит локальную сессию (OpenID Connect Back-Channel Logout 1.0). Хорошо работает для набора приложений одного провайдера, но требует, чтобы клиенты держали серверное состояние сессии.
5. Introspection (RFC 7662). Ресурс-сервер спрашивает у эмитента, жив ли токен. Отзыв мгновенный, но stateless-выигрыш исчезает полностью — это уже reference-модель с лишним слоем. Разумный гибрид: introspection только для чувствительных операций (перевод денег, смена настроек безопасности), локальная проверка подписи — для всего остального.
Отдельно: RFC 7009 Token Revocation описывает эндпоинт /revoke, но отзывает он refresh-токен на стороне эмитента. Access-токены, уже разошедшиеся по ресурс-серверам, он не догоняет — это прямо сказано в самом RFC.
Sender-constrained токены: как обесценить кражу
Bearer-семантика — фундаментальная слабость: украденный токен работает у кого угодно. Два стандартных способа привязать токен к клиенту:
DPoP (RFC 9449). Клиент генерирует пару ключей и отправляет вместе с запросом заголовок DPoP — короткий JWT, подписанный приватным ключом и содержащий HTTP-метод, URI и jti. Access-токен содержит cnf.jkt — отпечаток публичного ключа клиента. Ресурс-сервер проверяет, что доказательство подписано именно тем ключом и что htm/htu совпадают с фактическим запросом; украденный access-токен без приватного ключа бесполезен. На стороне ресурса надо предусмотреть кеш jti доказательств на короткое окно (anti-replay), допуск на часы для iat доказательства и жёсткую проверку htu — с нормализацией URI, но без «подрезания» пути.
mTLS-привязка (RFC 8705). Токен содержит cnf.x5t#S256 — отпечаток клиентского сертификата. Ресурс сверяет его с сертификатом текущего TLS-соединения. Естественный выбор для сервис-сервис взаимодействия; детали — в статье Транспортная безопасность.
Общее правило из RFC 9700: для высокорисковых сценариев bearer-токены следует заменять на sender-constrained. Для типового веб-приложения это избыточно, для банковского API — норма.
Где хранить токен на клиенте
Коротко, потому что тема пересекается с двумя другими статьями трека.
localStorage/sessionStorage— доступны любому JavaScript на странице: один XSS — и токен утёк вместе со всей сессией. Механика XSS и защита через CSP — в статье XSS, CSRF и клиентские атаки. Cookie сHttpOnly,Secure,SameSiteи префиксом__Host-— недоступна из JS, но требует защиты от CSRF для небезопасных методов.- Память вкладки + refresh в
HttpOnly-cookie — разумный компромисс для SPA: access живёт только в замыкании, при перезагрузке страницы обновляется по refresh. - BFF (backend-for-frontend) — браузер работает с обычной серверной сессией в cookie, а токены OAuth целиком остаются на сервере. Подход рекомендует черновик IETF OAuth 2.0 for Browser-Based Applications; для новых веб-приложений он сегодня самый защищённый по умолчанию. Мобильные приложения — Keychain (iOS) и Keystore (Android), никогда не обычные файлы настроек.
Подробности выбора для потоков OAuth — в статье OAuth 2.0 и OpenID Connect.
Когда JWT не нужен
Самый полезный раздел статьи. JWT решает конкретную задачу — проверка идентичности без обращения к общему состоянию. Если этой задачи нет, вы платите за отзыв, размер и сложность, не получая ничего.
JWT не нужен, если:
- У вас один бэкенд и один домен. Серверная сессия в
__Host--cookie проще, безопаснее и отзывается мгновенно. «Но мы же хотим горизонтально масштабироваться» — сессии в Redis масштабируются прекрасно и стоят одинGETна запрос; это дешевле, чем проверка RSA-подписи. - Вам нужен мгновенный выход и мгновенное изменение прав. Любая «правильная» реализация отзыва для JWT возвращает вас к обращению за состоянием — то есть к сессиям, но окольным путём.
- Токен читает только тот же сервис, который его выпустил. Тогда подпись доказывает вам то, что вы и так знаете; храните запись в базе.
- Вы собираетесь класть в токен много данных. Каждый килобайт умножается на число запросов.
- Вы хотите использовать JWT как CSRF-токен, как токен сброса пароля, как одноразовый код, как идентификатор в URL. Для всех этих задач нужна случайная строка в базе с TTL и флагом «использовано» — то есть ровно то, чего JWT не умеет.
JWT уместен, если:
- Токен выпускает один сервис, а проверяют многие (микросервисы, партнёрские API, федерация). Общий Redis между чужими контурами — плохая идея, общий публичный ключ — хорошая.
- Требуется офлайн- или edge-валидация: проверка на CDN, в API-шлюзе, в service mesh, без сетевого вызова вглубь.
- Сценарии, где формат прямо предписан стандартом: id-токен OIDC,
client_assertionпо RFC 7523, logout-токен, токены сервис-сервис.
Альтернативы, о которых стоит знать:
| Инструмент | Задача | Почему может быть лучше JWT |
|---|---|---|
| Серверная сессия + cookie | веб-приложение с одним бэкендом | мгновенный отзыв, крошечный размер, нет криптоошибок |
| Opaque + introspection (RFC 7662) | API с жёсткими требованиями к отзыву | эмитент всегда знает актуальный статус |
| PASETO | тот же сценарий, что и JWT | нет выбора алгоритма из токена — целый класс дефектов исключён по конструкции |
| Macaroons | делегирование с сужением прав | получатель может добавить ограничения, не обращаясь к эмитенту |
Подписанные URL (HMAC + expires) |
временный доступ к файлу | не требует ни сессии, ни токена в заголовке |
| API-ключ + HMAC-подпись запроса | сервис-сервис интеграция | подписывается тело запроса, а не только личность |
Формулировка для архитектурного решения (шаблон ADR — см. Архитектурные решения): «Мы выбираем self-contained токены, потому что проверку выполняют N независимых сервисов без общего хранилища; мы принимаем задержку отзыва до T минут и компенсируем её эпохой токенов для критичных операций».
Чек-лист приёмки
Пройдите по своей системе. Ещё раз: любые практические проверки — только на своей инфраструктуре или при наличии письменного разрешения владельца. Референс для методики — OWASP ASVS, разделы по сессиям и токенам, и OWASP WSTG, блок WSTG-SESS.
Верификация
- Список алгоритмов задан в коде,
noneнедостижим,algиз токена не влияет на выбор ключа -
kid— только ключ поиска в загруженном наборе;jku,x5u,jwkиз заголовка игнорируются -
iss,aud,typпроверяются строгим сравнением; обязательные claim перечислены явно -
leewayне больше 120 секунд, NTP настроен на всех узлах - Отказ верификации всегда даёт 401 с одинаковым телом; детали — только в лог с идентификатором инцидента
Ключи
- HMAC-секрет ≥ 256 бит из CSPRNG и приходит из секрет-менеджера; приватные ключи подписи — в KMS/HSM
- JWKS отдаёт только публичные ключи, кешируется на 5–15 минут, обновление ограничено по частоте
- Ротация с перекрытием отработана на стенде; есть runbook «ключ скомпрометирован»
Жизненный цикл
- Access-токен ≤ 15 минут; в claim нет ПДн и нет полного списка прав
- Refresh — ссылочный, хеш в БД, ротация при каждом использовании, реюз гасит семейство и порождает алерт
- Есть абсолютный потолок сессии; выход, смена пароля и изменение роли отзывают выданные токены
Эксплуатация
- Токены не попадают в логи, метрики, трассы,
Refererи URL; есть бюджет на размер токена в CI - Есть алерты на всплеск 401 «bad signature» и на отказы по неизвестному
kid; поток обмена refresh наблюдаем — см. Наблюдаемость
Типичные ошибки
- Писать свою реализацию JWT. Ручной разбор трёх сегментов, ручной base64url, ручное сравнение подписи через
==— набор дефектов гарантирован. Берите библиотеку из числа поддерживаемых и обновляйте её. - Считать, что HTTPS делает payload приватным. Транспорт защищает канал, а не хранилище: токен всё равно окажется в логах, в кеше браузера и в отчётах об ошибках.
- Ставить TTL в сутки, «чтобы пользователи не жаловались». Правильный ответ на жалобы — refresh-токен и прозрачное обновление, а не растягивание окна компрометации.
- Класть в токен полный список прав. Права меняются чаще, чем истекает токен; уволенный сотрудник ходит со старыми правами до
exp. Разделение ответственности между токеном и системой авторизации разбирается в статье Авторизация. - Один ключ на все окружения. Ключ со стенда рано или поздно окажется в чьём-то ноутбуке, а токены со стенда — валидными в проде.
- Не проверять
aud, потому что «у нас всё равно один сервис». Сервисов становится больше внезапно, а проверка не добавляется задним числом. - Обновлять JWKS на каждый неизвестный
kidбез ограничения частоты. Это превращает поток мусорных токенов в нагрузку на сервер авторизации. - Отдавать в ответе причину отказа. «Signature invalid» против «token expired» против «unknown issuer» — это оракул, который помогает подбирать. Наружу — единый 401.
- Забыть про гонки при ротации refresh. Два параллельных обмена без атомарного
UPDATE ... WHERE used_at IS NULLдают либо две живые цепочки, либо ложные срабатывания детектора и постоянные разлогины. - Использовать JWT там, где нужна одноразовость. Сброс пароля, подтверждение почты, приглашение, платёжный редирект — всё это требует записи в БД с флагом «использовано». Подпись не умеет считать до одного.
Итог
JWT — это способ обменять обращение к общему состоянию на криптографическую подпись. Всё остальное — следствия этого обмена:
- Формат. Три base64url-сегмента; подпись покрывает первые два в байтовом виде; payload читается любым держателем токена. Base64url — не шифрование.
- Верификация. Строгий порядок: allow-list алгоритмов → ключ из своего JWKS по
kid→ подпись → время с небольшим допуском →iss,aud,typ,scope. Ни один claim не используется до успешной проверки подписи. Пропуск любого шага — самостоятельная уязвимость, а не «недоработка». - Ключи. Приватный материал — в KMS, публичный — в JWKS с кешем и cooldown, ротация с перекрытием, отрепетированный сценарий компрометации.
- Срок жизни. Короткий access плюс ссылочный refresh с ротацией и обнаружением повторного использования. Ширина окна неотозванного доступа равна TTL access-токена — это цифра, которую надо назвать вслух и согласовать с владельцем продукта.
- Отзыв. Его нет «бесплатно». Есть выбор: терпеть задержку, платить одним обращением в кеш (
jti-денилист, эпоха), или вернуться к ссылочной модели. - Уместность. Если проверяет тот же сервис, что и выпустил, — вам нужна сессия, а не JWT. Инженерная зрелость здесь измеряется способностью не применять инструмент, который все применяют.
Источники
- RFC 7519 — JSON Web Token (JWT), RFC 7515 — JWS, RFC 7516 — JWE, RFC 7517 — JWK, RFC 7518 — JWA
- RFC 8725 — JSON Web Token Best Current Practices (BCP 225) — короткий и обязательный документ
- RFC 9068 — JWT Profile for OAuth 2.0 Access Tokens
- RFC 9700 — Best Current Practice for OAuth 2.0 Security (BCP 240)
- RFC 6750 — Bearer Token Usage, RFC 7009 — Token Revocation, RFC 7662 — Token Introspection
- RFC 9449 — DPoP, RFC 8705 — OAuth 2.0 Mutual-TLS, RFC 7523 — JWT Profile for Client Authentication
- OpenID Connect Core 1.0 и Back-Channel Logout 1.0
- OWASP JSON Web Token Cheat Sheet, OWASP Session Management Cheat Sheet, OWASP API Security Top 10
- NIST SP 800-63B — Digital Identity Guidelines (разделы про assertions), NIST SP 800-57 Part 1 — Key Management
- Tim McLean. Critical vulnerabilities in JSON Web Token libraries, 2015
- CWE-347, CWE-345, CWE-613, CWE-863, CWE-522, CWE-798, CWE-918
- PASETO и Macaroons (NDSS 2014) — альтернативные конструкции токенов
Что дальше
Мы разобрали, как система убеждается, что перед ней именно тот субъект, за которого он себя выдаёт, и как этот факт переносится между сервисами. Токен отвечает на вопрос «кто», но почти ничего не говорит о том, «что этому кому-то можно». Следующая статья — про правила доступа: модели RBAC, ABAC и ACL, изоляция арендаторов и, самое главное, где именно в коде должна стоять проверка прав, чтобы её нельзя было обойти.
Авторизация: RBAC, ABAC, ACL, multi-tenancy и проверка прав в коде