Коротко. JWT — это строка из трёх частей через точку: header.payload.signature. Первые две — обычный base64url, не шифрование: содержимое читает кто угодно. Подпись доказывает, что данные не подменили. Декодировать токен можно локально одной командой, а вот проверить его подлинность — только зная ключ. Секреты в payload класть нельзя.

Что такое JWT и из чего он состоит
JWT (JSON Web Token) — формат передачи набора утверждений о пользователе или клиенте в виде компактной строки. Стандарт описан в RFC 7519, а конкретно подписанный вариант (JWS) — в RFC 7515. Практически всегда, когда говорят «JWT-токен», имеют в виду именно подписанный JWS-вариант из трёх частей.
Строка выглядит так — три блока, разделённые точками:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJodHRwczovL2F1dGguZXhhbXBsZS5jb20iLCJzdWIiOiIxMDI0IiwiYXVkIjoiYXBpLmV4YW1wbGUuY29tIiwiZXhwIjoxNzk4NzYxNjAwLCJpYXQiOjE3OTg3NTgwMDAsImp0aSI6IjlmMmIxYzdhIiwicm9sZSI6ImVkaXRvciJ9.CIcPXbJ7G5DFRj67O7qonOnEZC9tbupi6_xYErENz18
Первая часть — header. Это JSON с типом токена и алгоритмом подписи:
{"alg":"HS256","typ":"JWT"}
Вторая часть — payload (полезная нагрузка). Это JSON с утверждениями — claim'ами:
{
"iss": "https://auth.example.com",
"sub": "1024",
"aud": "api.example.com",
"exp": 1798761600,
"iat": 1798758000,
"jti": "9f2b1c7a",
"role": "editor"
}
Третья часть — signature. Это подпись первых двух частей: сервер берёт строку header.payload ровно в том виде, в каком она пришла, и считает от неё HMAC или цифровую подпись. Строка, от которой считают подпись, называется signing input — именно поэтому нельзя «пересобрать» токен из декодированного JSON: любой лишний пробел изменит байты и сломает проверку.
base64url — это кодирование, а не шифрование
Обе первые части кодируются base64url (RFC 4648, раздел 5). От обычного base64 он отличается двумя вещами: вместо символов + и / используются - и _, а хвостовые символы выравнивания = отбрасываются. Сделано это ради того, чтобы токен можно было без экранирования положить в URL, заголовок или cookie.
Ключевой вывод: base64url обратим без всякого ключа. Payload не защищён от чтения — он защищён только от изменения. Если внутри токена лежит номер телефона, e-mail или внутренний идентификатор договора, эти данные видит любой, кто получил токен: браузер пользователя, прокси, система логирования, коллега, которому прислали «вот мой запрос, посмотри».
Есть отдельный формат JWE (зашифрованный JWT) — там пять частей вместо трёх, и содержимое действительно нечитаемо без ключа. Но в вебе он встречается редко, и если у вас в токене три части — это обычный подписанный JWS, содержимое открыто.
Стандартные claim'ы
RFC 7519 закрепляет семь зарезервированных полей. Остальные вы добавляете сами (role, tenant_id, scope и т. п.).
| Claim | Расшифровка | Что означает | Что бывает, если не проверять |
|---|---|---|---|
iss | issuer | Кто выпустил токен | Примете токен постороннего эмитента, если он подписан ключом, который у вас в доверенных |
sub | subject | О ком токен — обычно идентификатор пользователя | Привязка запроса не к тому аккаунту |
aud | audience | Кому предназначен токен | Токен, выданный соседнему сервису того же провайдера, пройдёт как свой |
exp | expiration time | До какого момента действителен. Unix-время в секундах, UTC | Украденный токен работает бессрочно |
nbf | not before | Раньше этого времени токен недействителен | Заранее выпущенный токен начнёт работать преждевременно |
iat | issued at | Когда выпущен | При инциденте нечем отсечь все токены, выпущенные до момента X |
jti | JWT ID | Уникальный идентификатор конкретного токена | Нечем отозвать один токен и нечем ловить повторное предъявление |
Обратите внимание на exp: это именно Unix-секунды, а не миллисекунды. Значение 1798761600 — это 1 января 2027 года, 00:00 UTC. Частая ошибка на стыке JS и бэкенда — положить туда Date.now(), то есть миллисекунды, и получить токен, который «истекает» через пятьдесят тысяч лет.
Как расшифровать JWT: три способа
Не вставляйте боевой токен в чужие онлайн-декодеры: многие отправляют его на сервер, и что с ним там произойдёт, вы не контролируете. Наш декодер работает целиком в браузере и токен никуда не отправляет — это можно проверить самому: откройте DevTools, вкладку «Сеть», и декодируйте токен, запроса с ним не появится. JWT — предъявительский документ: у кого токен, тот и пользователь, пока не истёк exp. Вставили действующий токен в чужую форму — считайте, что отдали доступ. Для разбора берите токен из тестового контура или из уже отозванной сессии. Если боевой токен всё-таки куда-то попал — завершите сессию и выпустите новый. Хорошая новость: декодирование не требует секрета, поэтому разобрать токен можно локально, никуда его не отправляя.
Отдельно про терминологию. «Расшифровать JWT» — распространённое, но неточное выражение. Расшифровка предполагает ключ; здесь ключ не нужен, потому что шифрования и не было. Правильное слово — декодировать. Это важно не ради педантизма: как только вы поймёте, что payload читается без ключа, станет очевидно, почему в него нельзя класть чувствительные данные.
Способ 1: локально в терминале
Самый безопасный вариант — токен не покидает машину. Проблема одна: base64url нужно превратить в обычный base64 и вернуть выравнивание, иначе base64 -d либо ругнётся, либо молча обрежет последние байты. Рабочий вариант для bash и zsh:
JWT='eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJodHRwczovL2F1dGguZXhhbXBsZS5jb20iLCJzdWIiOiIxMDI0IiwiYXVkIjoiYXBpLmV4YW1wbGUuY29tIiwiZXhwIjoxNzk4NzYxNjAwLCJpYXQiOjE3OTg3NTgwMDAsImp0aSI6IjlmMmIxYzdhIiwicm9sZSI6ImVkaXRvciJ9.CIcPXbJ7G5DFRj67O7qonOnEZC9tbupi6_xYErENz18'
for i in 1 2; do
p=$(printf '%s' "$JWT" | cut -d. -f$i | tr '_-' '/+')
while [ $(( ${#p} % 4 )) -ne 0 ]; do p="$p="; done
printf '%s' "$p" | base64 -d; echo
done
Вывод — сначала header, затем payload:
{"alg":"HS256","typ":"JWT"}
{"iss":"https://auth.example.com","sub":"1024","aud":"api.example.com","exp":1798761600,"iat":1798758000,"jti":"9f2b1c7a","role":"editor"}
Если на машине есть Python 3 (а на серверах он есть почти всегда), удобнее один вызов — он сам разбирается с base64url и форматирует JSON:
python3 -c "import base64,json,sys;p=sys.argv[1].split('.')[1];print(json.dumps(json.loads(base64.urlsafe_b64decode(p+'='*(-len(p)%4))),indent=2,ensure_ascii=False))" "$JWT"
Замените индекс [1] на [0], чтобы посмотреть header. Флаг ensure_ascii=False нужен, чтобы кириллица в claim'ах не превратилась в escape-последовательности.
Способ 2: в консоли браузера
Когда токен уже лежит в приложении и нужно быстро понять, что в нём, удобнее декодировать прямо в DevTools. Наивный atob(payload) сломается на двух вещах: символы - и _ и отсутствующее выравнивание. Плюс atob отдаёт «двоичную строку», и кириллица в ней превратится в мусор, если не прогнать её через TextDecoder. Рабочий вариант:
const decodeJwtPart = (part) => {
const b64 = part.replace(/-/g, '+').replace(/_/g, '/');
const padded = b64.padEnd(Math.ceil(b64.length / 4) * 4, '=');
const bytes = Uint8Array.from(atob(padded), (c) => c.charCodeAt(0));
return JSON.parse(new TextDecoder().decode(bytes));
};
const [header, payload] = token.split('.');
console.log(decodeJwtPart(header));
console.log(decodeJwtPart(payload));
Здесь же удобно проверить срок жизни: new Date(payload.exp * 1000). Если получилась дата из 1970 года — в exp положили миллисекунды вместо секунд.
Способ 3: онлайн-декодер
Онлайн-инструмент уместен для тестовых токенов и для быстрой проверки структуры — когда нужно за пять секунд понять, какой алгоритм в header, какие claim'ы есть и когда истекает срок. Наш декодер — /jwt: он разбирает три части, показывает header и payload как читаемый JSON и подсвечивает exp и nbf относительно текущего времени.
Правило простое: тестовый токен — можно, боевой — нет. Если разобрать нужно именно боевой токен, используйте способы 1 и 2 — они не требуют ни секрета, ни сети.

Подпись и проверка: чем декодировать отличается от проверить
Это два разных действия, и путаница между ними — источник целого класса уязвимостей.
- Декодировать — прочитать header и payload. Ключ не нужен. Ничего не доказывает: содержимое мог написать кто угодно.
- Проверить (verify) — пересчитать подпись по signing input и сравнить с третьей частью, а затем сверить claim'ы. Требует ключа. Только это даёт основание доверять содержимому.
Библиотека, у которой есть метод вида «decode без проверки», почти всегда имеет и метод «verify». Если в коде вызывается первый, а решение об авторизации принимается по его результату — авторизации фактически нет.
Симметричные и асимметричные алгоритмы
| Алгоритм | Тип | Чем подписывают | Чем проверяют | Когда уместен |
|---|---|---|---|---|
| HS256 / HS384 / HS512 | Симметричный, HMAC | Общий секрет | Тот же секрет | Один сервис и выпускает, и проверяет токены |
| RS256 / RS384 / RS512 | Асимметричный, RSA PKCS#1 v1.5 | Приватный ключ | Публичный ключ | Много проверяющих сторон, публичный JWKS |
| PS256 / PS384 / PS512 | Асимметричный, RSA-PSS | Приватный ключ | Публичный ключ | Современная замена RS* при том же типе ключа |
| ES256 / ES384 / ES512 | Асимметричный, ECDSA | Приватный ключ | Публичный ключ | Нужна компактная подпись и небольшие ключи |
| EdDSA (Ed25519) | Асимметричный | Приватный ключ | Публичный ключ | Современный вариант там, где его поддерживают обе стороны |
Разница практическая. При HS256 всякий, кто может проверить токен, может и выпустить новый: ключ один и тот же. Как только проверяющих сторон становится больше одной — например, десять микросервисов принимают токены одного центра авторизации, — симметричная схема означает, что секрет размазан по десяти сервисам, и компрометация любого из них даёт возможность выпускать токены от имени кого угодно. Асимметричная схема эту проблему снимает: приватный ключ живёт только в центре авторизации, сервисы получают публичный.
Проверка подписи HS256 вручную
Полезно для отладки: сравнить свою подпись с той, что в токене, и понять, тот ли секрет используется.
SIGNING_INPUT="${JWT%.*}" # всё до последней точки
SECRET='test-secret-not-for-production'
printf '%s' "$SIGNING_INPUT" \
| openssl dgst -sha256 -mac HMAC -macopt "key:$SECRET" -binary \
| openssl base64 -A | tr '+/' '-_' | tr -d '='
echo "actual: ${JWT##*.}"
Две строки должны совпасть символ в символ. Если нет — либо секрет другой, либо signing input собран неверно (например, вы пересобрали его из отформатированного JSON вместо исходных байтов).
Проверка подписи RS256
Здесь нужен публичный ключ эмитента — обычно он публикуется по JWKS-эндпоинту провайдера. Подпись сохраняется в бинарный файл и проверяется штатной командой OpenSSL:
printf '%s' "${JWT%.*}" > signing_input.bin
p="${JWT##*.}"
p=$(printf '%s' "$p" | tr '_-' '/+')
while [ $(( ${#p} % 4 )) -ne 0 ]; do p="$p="; done
printf '%s' "$p" | base64 -d > sig.bin
openssl dgst -sha256 -verify pub.pem -signature sig.bin signing_input.bin
# Verified OK
Если изменить в signing input хотя бы один символ, команда вернёт Verification failure — ровно то поведение, ради которого подпись и существует.
Что обязательно проверять на сервере
- Подпись — ключом, соответствующим заявленному эмитенту.
- Алгоритм — по белому списку. Список задаётся конфигурацией приложения, а не берётся из поля
algсамого токена. expиnbf— с небольшим допуском на расхождение часов, обычно десятки секунд. Больше минуты допускать не стоит.iss— строго один из доверенных эмитентов.aud— строго ваш сервис.kid, если он есть, — только как ключ поиска в заранее известном справочнике ключей, никогда как путь к файлу или URL.
Классические ошибки и атаки на JWT
Практики безопасного применения собраны в RFC 8725 (JWT Best Current Practices). Ниже — то, что чаще всего встречается в реальном коде.
| Ошибка | Чем грозит | Как проверить | Как исправить |
|---|---|---|---|
Принимается alg: none | Полный обход подписи: токен без подписи считается валидным, роль подставляется любая | В тестовом контуре подменить header на {"alg":"none","typ":"JWT"}, оставить payload, третью часть сделать пустой | Жёсткий белый список алгоритмов на стороне верификации; none в нём не должно быть никогда |
| Подмена RS256 на HS256 | Публичный ключ, который лежит в открытом доступе, используется как HMAC-секрет — атакующий подписывает токены сам | Поменять alg на HS256 и подписать токен публичным ключом эмитента | Алгоритм жёстко привязан к типу ключа в конфигурации; проверка не читает alg из токена как источник истины |
Не проверяется exp | Украденный токен работает бессрочно | Выпустить токен с exp в прошлом и отправить запрос | Проверять exp и nbf; допуск на часы — секунды, не часы |
| Словарный секрет у HS256 | Подбор ключа офлайн по одному перехваченному токену, дальше — выпуск любых токенов | Прогнать тестовый токен по короткому словарю паролей | Секрет не меньше 256 бит из криптостойкого генератора, хранение в секрет-менеджере, плановая ротация |
| Чувствительные данные в payload | Данные читает каждый, кто получил токен: браузер, прокси, логи, скриншот в чате | Просто декодировать боевой токен и прочитать payload | В payload — только идентификаторы; всё остальное сервер берёт у себя по sub |
Не проверяются iss и aud | Токен, выданный для другого сервиса того же провайдера, принимается как свой | Отправить токен с другим aud и посмотреть, пройдёт ли | Сверять оба поля со строгим списком допустимых значений |
| Нет механизма отзыва | Увольнение, смена пароля, кража устройства не завершают сессию — токен живёт до exp | Сменить пароль и повторить запрос со старым токеном | Короткий exp плюс denylist по jti или счётчик версии токена у пользователя |
kid подставляется в путь или запрос | Обращение к постороннему ключу, в тяжёлых случаях — чтение чужих файлов или SQL-инъекция | Подставить в kid значение вида ../../dev/null | kid — только ключ поиска в справочнике; неизвестное значение = отказ |
Правило про payload. Всё, что вы кладёте в JWT, вы фактически публикуете. Прежде чем добавить поле, спросите: готов ли я показать это значение в скриншоте, который пользователь пришлёт в поддержку? Если нет — поле в токене не место.

Где хранить токен на клиенте
Правильного ответа тут нет — есть осознанный размен между двумя классами атак.
localStorage / sessionStorage. Токен доступен из JavaScript, его удобно подставлять в заголовок Authorization: Bearer. CSRF в этой схеме практически не работает: браузер не подставляет заголовок автоматически, атакующему сайту нечего «прицепить» к запросу. Но любой XSS — включая XSS в стороннем скрипте аналитики или виджете чата — читает хранилище и уносит токен целиком. Дальше токен действует до exp с любого устройства.
httpOnly cookie. Токен недоступен из JavaScript, поэтому XSS не может его вычитать и вынести наружу. Взамен появляется CSRF: браузер сам прикладывает cookie к запросам на ваш домен, и сторонняя страница может инициировать действие от имени пользователя. Лечится атрибутом SameSite=Lax или Strict, отдельным CSRF-токеном для изменяющих запросов и обязательным Secure. Проверить выставленные флаги можно инструментом /cookie.
Честная формулировка размена звучит так: httpOnly cookie не защищает от XSS — она защищает от кражи токена при XSS. Скрипт атакующего всё равно может выполнять запросы от имени пользователя, пока страница открыта, но он не сможет унести токен и пользоваться им завтра со своего сервера. Это существенная разница в масштабе ущерба, поэтому для веб-приложений с сессиями в браузере httpOnly cookie обычно предпочтительнее.
Для мобильных приложений и server-to-server интеграций вопрос не стоит: там нет браузера, токен хранится в защищённом хранилище платформы или в секрет-менеджере, а передаётся заголовком Authorization: Bearer. Что происходит, когда заголовок отсутствует или неверен, разобрано в статье про ошибку 401 Unauthorized.
Срок жизни, refresh-токены и отзыв
Главная неудобная особенность JWT: сервер не хранит состояние, а значит по умолчанию не может ничего отменить. Выпущенный токен действителен до exp — даже если пользователь сменил пароль, лишился прав или уволился. Отсюда два следствия.
Access-токен должен быть коротким. Минуты, а не дни. Тогда окно, в котором украденный токен полезен атакующему, ограничено, и отдельный механизм отзыва нужен реже.
Refresh-токен — это не «то же самое, но подольше». Он предъявляется только на один эндпоинт обновления, хранится строже и, в отличие от access-токена, обычно всё-таки лежит в базе — то есть его можно отозвать. Рабочая схема: короткий access-токен в памяти приложения, refresh-токен в httpOnly cookie, ротация при каждом обновлении.
Ротация с обнаружением повтора. При каждом обновлении выдаётся новый refresh-токен, а старый помечается использованным. Если использованный токен предъявляют повторно — это сигнал, что копия утекла: вся цепочка сессии завершается принудительно. Это дешёвый способ поймать кражу, который не требует ничего, кроме одной таблицы.
Если отзыв «здесь и сейчас» нужен обязательно, вариантов два: denylist отозванных jti с TTL, равным остатку срока жизни токена (в Redis это дёшево), или счётчик версии токенов у пользователя — при смене пароля версия увеличивается, и все токены со старым номером перестают проходить проверку. Оба варианта возвращают в схему состояние на сервере, и это нормально: полностью stateless-аутентификация с мгновенным отзывом не бывает.

JWT или серверные сессии
JWT часто выбирают по инерции, потому что «так делают в микросервисах». Для монолита с одной базой это обычно проигрыш: классическая сессия проще, отзывается мгновенно и не тащит килобайт в каждый запрос.
| Критерий | JWT (stateless) | Серверная сессия |
|---|---|---|
| Хранение состояния | Ничего в базе — всё в токене | Запись в БД или Redis плюс cookie с идентификатором |
| Мгновенный отзыв | Сложно: нужен denylist, то есть состояние возвращается | Тривиально: удалить запись |
| Много независимых сервисов | Плюс: проверка по публичному ключу без похода в общее хранилище | Нужен общий стор сессий, доступный всем сервисам |
| Объём на каждый запрос | Сотни байт, при богатом payload — килобайты | Десятки байт в cookie |
| Смена прав пользователя | Старый токен несёт старую роль до exp | Новые права действуют со следующего запроса |
| Сложность реализации | Выше: алгоритмы, ключи, ротация, refresh | Ниже: почти всегда встроено во фреймворк |
| Когда уместно | Межсервисное взаимодействие, API для сторонних клиентов, SSO и OIDC | Монолит, классическое веб-приложение с одной базой |
Практический ориентир: если у вас один бэкенд и одна база — берите сессии. Если токен должен проверяться несколькими независимыми сервисами или сторонними клиентами и поход в общее хранилище на каждый запрос неприемлем — берите JWT и сразу закладывайте короткий exp и механизм отзыва.
Как проверить свой токен
Порядок разбора, когда «токен не работает» или непонятно, что в нём:
- Разберите структуру. Возьмите тестовый токен и откройте /jwt. Убедитесь, что частей ровно три, header парсится, а payload читается как JSON. Если частей пять — это JWE, и его содержимое без ключа не прочитать.
- Посмотрите
alg.noneв боевом токене — красный флаг. Смена алгоритма между окружениями — вторая по частоте причина отказов проверки. - Сверьте
expиnbfс текущим временем. Токен, выглядящий «свежим», часто оказывается просроченным на несколько минут из-за расхождения часов между сервисами. - Проверьте
issиaud. Токен, выданный для стенда, не подойдёт продакшену — и это правильное поведение, а не баг. - Пересчитайте подпись локально командами выше, если есть доступ к ключу.
- Проверьте транспорт. Токен, отданный по HTTP, перехватывается по дороге. Убедитесь, что HTTPS настроен и работает без предупреждений: /ssl. Заголовки ответа приложения смотрите через /http-headers, флаги cookie — через /cookie.
- Оцените обвязку. Сканер /security покажет заголовки безопасности, включая разбор Content-Security-Policy, — это то, что снижает шанс на XSS, а значит и на кражу токена из браузера.
Сам декодер, помимо разбора, отмечает отсутствующие claim'ы — например, что без iss эмитент не верифицируется — и объясняет, чем каждый пропуск грозит. Смежные материалы: безопасность API, проверка безопасности сайта и разбор HTTP-заголовков.
Частые вопросы
Можно ли расшифровать JWT без ключа?
Да, если под «расшифровать» понимать «прочитать содержимое». Header и payload — это base64url, а не шифр: они декодируются одной командой без всякого секрета. Ключ нужен только для того, чтобы проверить подпись, то есть убедиться, что содержимое не подменили. Исключение — формат JWE (пять частей вместо трёх): он действительно зашифрован, и без ключа payload не прочитать.
Как получить JWT-токен для работы с API?
Стандартный путь: отправить логин и пароль либо client_id и client_secret на эндпоинт авторизации провайдера и получить в ответ JSON с полями вида access_token, expires_in и часто refresh_token. Дальше токен подставляется в заголовок Authorization: Bearer <token> каждого запроса. Точный адрес эндпоинта и формат тела запроса всегда смотрите в документации конкретного API — единого для всех варианта не существует.
Как работать с JWT-токеном в 1С?
Принцип тот же, что и в любом другом клиенте, конфигуратор здесь ничего не меняет. Нужно выполнить HTTP-запрос к эндпоинту авторизации внешнего сервиса, разобрать JSON-ответ и достать из него access-токен, а затем подставлять его в заголовок Authorization со значением Bearer <token> у всех последующих запросов. Ключевой практический момент: токен имеет срок жизни, поэтому его стоит кэшировать вместе с моментом истечения и обновлять заранее — за минуту-две до exp, а не по факту получения ошибки 401. Второй момент: секрет для получения токена не должен лежать в коде обработки — храните его в защищённом реквизите или в настройках подключения.
Почему сервер отвечает 401, хотя токен на вид правильный?
Самые частые причины по убыванию: истёк exp; не совпадает aud (токен выдан для другого сервиса); расходятся часы между эмитентом и потребителем, и nbf ещё не наступил; токен подписан ключом другого окружения; заголовок передан без префикса Bearer или с лишним пробелом. Разбор кода 401 и схем авторизации — в отдельной статье.
Какой длины должен быть секрет для HS256?
Не короче размера выхода хеш-функции — для HS256 это 256 бит, то есть 32 байта случайных данных из криптостойкого генератора. Осмысленная фраза, даже длинная, не годится: стойкость определяется энтропией, а не количеством символов. Секрет из словаря подбирается офлайн по одному перехваченному токену, после чего атакующий выпускает любые токены сам.
Нужно ли шифровать JWT?
В большинстве случаев нет — вместо этого не кладите в payload то, что не должно быть публичным. Формат JWE существует и решает задачу, но добавляет управление ещё одним ключом и заметно усложняет отладку. Если данные действительно секретны, обычно проще и надёжнее оставить в токене только идентификатор, а сами данные держать на сервере.
Что делать, если боевой токен попал в чужой декодер?
Считать его скомпрометированным. Завершите соответствующую сессию, отзовите refresh-токен, при наличии denylist добавьте jti. Если механизма отзыва нет — смените пароль пользователя или увеличьте счётчик версии токенов, если такой предусмотрен. Дополнительно проверьте, не попал ли токен в логи прокси или системы мониторинга: типовая утечка — токен в query-параметре, который пишется в access-лог целиком.
Чеклист
- Помню, что payload — это base64url, а не шифр: чувствительных данных внутри нет.
- Боевые токены декодирую только локально — в терминале или в консоли браузера; онлайн-декодер использую для тестовых.
- Верификация принимает алгоритм строго по белому списку из конфигурации;
noneневозможен. - Алгоритм жёстко связан с типом ключа — подмена RS256 на HS256 не проходит.
- Проверяю
exp,nbf,issиaud, допуск на расхождение часов — секунды. kidиспользуется только как ключ поиска в известном справочнике.- Секрет HS256 — не менее 256 бит из криптостойкого генератора, лежит в секрет-менеджере, ротируется.
- Access-токен короткий; refresh-токен ротируется, повторное использование обнаруживается.
- Есть ответ на вопрос «как отозвать токен прямо сейчас»: denylist по
jtiили версия токенов у пользователя. - Выбор хранилища на клиенте сделан осознанно: localStorage — риск XSS, httpOnly cookie — нужна защита от CSRF и флаг
Secure. - Токен никогда не передаётся в query-параметре — только в заголовке или cookie.
- Транспорт только HTTPS; сертификат и заголовки безопасности проверены.
- Для монолита с одной базой честно рассмотрел вариант обычных серверных сессий.