Безопасность приложений JWT и токены: устройство, подпись, срок жизни, отзыв и когда JWT не нужен
0%

JWT и токены: устройство, подпись, срок жизни, отзыв и когда JWT не нужен

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-токена: части, область подписи и порядок проверки

Компактная сериализация 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 до того, как им начнут подписывать, а старый исчезает после того, как истекут все выданные им токены.

Модель хранения ключей и связанных сущностей:

Обратите внимание на private_ref: приватный ключ не хранится в прикладной БД. Он живёт в KMS/HSM, а приложение вызывает операцию подписи — так компрометация дампа базы не даёт возможности выпускать токены.

Срок жизни: почему «поставим сутки» — плохая идея

Временная шкала токенов: окно неотозванного доступа, refresh-ротация, iat/nbf/exp и допуск на часы

Схема выдачи почти всегда двухуровневая: короткий access-токен для запросов к API и длинный refresh-токен, который обменивается на новый access.

Ориентиры по 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, либо ротацию с обнаружением повторного использования.

Ключевой момент — атомарность. Два параллельных обмена одним 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», — это разные способы вернуть в схему кусочек состояния. Вопрос лишь в том, какой кусочек и какой ценой.

Разберём варианты по существу.

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 наблюдаем — см. Наблюдаемость

Типичные ошибки

  1. Писать свою реализацию JWT. Ручной разбор трёх сегментов, ручной base64url, ручное сравнение подписи через == — набор дефектов гарантирован. Берите библиотеку из числа поддерживаемых и обновляйте её.
  2. Считать, что HTTPS делает payload приватным. Транспорт защищает канал, а не хранилище: токен всё равно окажется в логах, в кеше браузера и в отчётах об ошибках.
  3. Ставить TTL в сутки, «чтобы пользователи не жаловались». Правильный ответ на жалобы — refresh-токен и прозрачное обновление, а не растягивание окна компрометации.
  4. Класть в токен полный список прав. Права меняются чаще, чем истекает токен; уволенный сотрудник ходит со старыми правами до exp. Разделение ответственности между токеном и системой авторизации разбирается в статье Авторизация.
  5. Один ключ на все окружения. Ключ со стенда рано или поздно окажется в чьём-то ноутбуке, а токены со стенда — валидными в проде.
  6. Не проверять aud, потому что «у нас всё равно один сервис». Сервисов становится больше внезапно, а проверка не добавляется задним числом.
  7. Обновлять JWKS на каждый неизвестный kid без ограничения частоты. Это превращает поток мусорных токенов в нагрузку на сервер авторизации.
  8. Отдавать в ответе причину отказа. «Signature invalid» против «token expired» против «unknown issuer» — это оракул, который помогает подбирать. Наружу — единый 401.
  9. Забыть про гонки при ротации refresh. Два параллельных обмена без атомарного UPDATE ... WHERE used_at IS NULL дают либо две живые цепочки, либо ложные срабатывания детектора и постоянные разлогины.
  10. Использовать JWT там, где нужна одноразовость. Сброс пароля, подтверждение почты, приглашение, платёжный редирект — всё это требует записи в БД с флагом «использовано». Подпись не умеет считать до одного.

Итог

JWT — это способ обменять обращение к общему состоянию на криптографическую подпись. Всё остальное — следствия этого обмена:

  1. Формат. Три base64url-сегмента; подпись покрывает первые два в байтовом виде; payload читается любым держателем токена. Base64url — не шифрование.
  2. Верификация. Строгий порядок: allow-list алгоритмов → ключ из своего JWKS по kid → подпись → время с небольшим допуском → iss, aud, typ, scope. Ни один claim не используется до успешной проверки подписи. Пропуск любого шага — самостоятельная уязвимость, а не «недоработка».
  3. Ключи. Приватный материал — в KMS, публичный — в JWKS с кешем и cooldown, ротация с перекрытием, отрепетированный сценарий компрометации.
  4. Срок жизни. Короткий access плюс ссылочный refresh с ротацией и обнаружением повторного использования. Ширина окна неотозванного доступа равна TTL access-токена — это цифра, которую надо назвать вслух и согласовать с владельцем продукта.
  5. Отзыв. Его нет «бесплатно». Есть выбор: терпеть задержку, платить одним обращением в кеш (jti-денилист, эпоха), или вернуться к ссылочной модели.
  6. Уместность. Если проверяет тот же сервис, что и выпустил, — вам нужна сессия, а не JWT. Инженерная зрелость здесь измеряется способностью не применять инструмент, который все применяют.

Источники

Что дальше

Мы разобрали, как система убеждается, что перед ней именно тот субъект, за которого он себя выдаёт, и как этот факт переносится между сервисами. Токен отвечает на вопрос «кто», но почти ничего не говорит о том, «что этому кому-то можно». Следующая статья — про правила доступа: модели RBAC, ABAC и ACL, изоляция арендаторов и, самое главное, где именно в коде должна стоять проверка прав, чтобы её нельзя было обойти.

Авторизация: RBAC, ABAC, ACL, multi-tenancy и проверка прав в коде

Нашли неточность? Выделите фрагмент текста — рядом появится жучок.

Нужен разбор именно вашей ситуации?

Статья описывает общий случай. Если у вас частный — можно разобрать его отдельно, платно. А если не хватает целого материала, предложите тему: её оплачивают вскладчину, и она выходит открытой для всех.

Доска запросов