awesome-everything EN
↑ Обратно к восхождению

Безопасность

Ловушки безопасности JWT: путаница alg, отсутствие отзыва и где утекают токены

Суть JWT настолько безопасен, насколько правильно ты его проверяешь. Классические провалы — приём alg:none, путаница RS256→HS256, отсутствие отзыва и токены в localStorage — растут из доверия токену вместо того, чтобы зафиксировать ожидаемое сервером.
Высота — путь к senior
НольJuniorMiddleSenior
Ты на junior-высоте — поверхность
◷ 16 min

Пентестер логинится как обычный пользователь, копирует JWT, меняет в заголовке "alg":"RS256" на "alg":"HS256", правит payload на "role":"admin" и переподписывает токен, используя публичный RSA-ключ сервера как HMAC-секрет. Сервер читает заголовок, уходит в HMAC-ветку, проверяет тем же публичным ключом — и пропускает. Приватный ключ не понадобился. «Секрет» всё это время лежал в открытом эндпоинте JWKS.

Токен не значит ничего, пока ты его не проверил

JWT — это три base64url-части: заголовок, payload и подпись. Заголовок несёт alg (какой алгоритм), payload несёт claims (sub, role, exp), а подпись — то, что делает первые две части неподделываемыми, если ты проверяешь её правильно. Опасная ментальная модель — считать декодированный payload фактами. Это не факты. Любой может base64-декодировать и переписать payload в консоли браузера. Между атакующим и "role":"admin" стоит только проверка подписи, и каждая ловушка ниже — это способ, которым проверка тихо ничего не делает.

RFC 8725 (JSON Web Token Best Current Practices, 2020) существует именно потому, что гибкость спеки — позволять токену самому называть свой алгоритм — это корневая причина большинства атак на JWT. Его прямое правило: приложение должно решать, какие алгоритмы и ключи допустимы, и проверять токен против этого, а не против того, что просит заголовок.

alg:none — пустая подпись, которую некоторые библиотеки принимали

Спека JWT определяет значение "alg":"none", означающее «этот токен не подписан». Оно существует для токенов, уже защищённых другим слоем. Катастрофа: ранние версии библиотек, получив токен с "alg":"none" и пустым сегментом подписи, возвращали «валиден». Атакующий просто ставит в заголовок none, удаляет подпись, пишет любой нужный payload — и аутентифицирован как кто угодно.

Фикс — одна строка дисциплины: верификатору надо сказать ожидаемый алгоритм и отвергать всё остальное. jwt.verify(token, key, { algorithms: ["RS256"] }) — allowlist без none делает атаку невозможной. Никогда не вызывай функцию только-декодирования (jwt.decode, которая пропускает подпись) и не действуй по её claims.

Путаница алгоритмов RS256 → HS256

Это более тонкий и более злой родственник — тот, что в начале урока. Асимметричный RS256 использует пару ключей: приватный подписывает, публичный проверяет, и публичный предназначен для шаринга — он лежит в твоём /.well-known/jwks.json. Симметричный HS256 использует один секрет и для подписи, и для проверки.

Атака эксплуатирует сервер, который выбирает путь проверки из заголовка alg:

  1. Атакующий берёт публичный ключ (он публичный).
  2. Атакующий подделывает токен с "alg":"HS256" и подписывает его HMAC-SHA256, используя строку публичного ключа как HMAC-секрет.
  3. Сервер читает HS256, уходит в HMAC-ветку кода и выполняет HMAC-SHA256 со своим публичным ключом в роли секрета — ровно ту операцию, что только что сделал атакующий.
  4. Подписи совпадают. Поддельный admin-токен принят.

Всё это работает, потому что сервер доверился alg из заголовка и использовал один ключ с двумя алгоритмами. RFC 8725 называет обе защиты напрямую: зафиксируй ожидаемый алгоритм (явный allowlist, переданный в verify) и привяжи каждый ключ ровно к одному алгоритму, чтобы публичный RS256-ключ никогда не попал в HMAC-верификатор.

ЛовушкаЧто делает атакующийФикс
Принимается alg: noneСтавит alg в none, опустошает подпись, переписывает payloadПередай allowlist алгоритмов в verify; никогда не принимай none
Путаница RS256 → HS256Подписывает HS256, используя публичный RSA-ключ как HMAC-секретЗафиксируй алгоритм; привяжи каждый ключ к одному алгоритму
Нет отзываРеплеит украденный токен, пока он не истечётКороткий TTL access (5–15 мин) + ротация refresh-токена
Токен в localStorageXSS читает токен через JavaScript и выкачивает егоhttpOnly-cookie или in-memory + короткий TTL
Слабый HMAC-секретБрутфорсит секрет офлайн из перехваченного токена≥32 случайных байта (256 бит) из CSPRNG

Нет отзыва: украденный JWT валиден до истечения

Определяющее свойство stateless-JWT — сервер его не хранит: он просто проверяет подпись и доверяет claim exp. Обратная сторона — производственная головная боль: встроенного logout нет. Если токен украден (XSS, утёкший лог, прокси), отозвать его нельзя. Вызов «logout»-эндпоинта, который удаляет клиентскую cookie, никак не влияет на токен, который атакующий уже скопировал. Он остаётся валидным до exp.

Митигация сеньора — держать радиус поражения малым через время. Выдавай короткоживущие access-токены — стандартный диапазон 5–15 минут — в паре с долгоживущим refresh-токеном, который меняется на новые access-токены. Теперь украденный access-токен умирает за минуты. Refresh-токен — самый ценный секрет, поэтому он получает ротацию: при каждом использовании сервер выдаёт новый refresh-токен и инвалидирует старый. Если старый refresh-токен когда-нибудь зареплеят (атакующий использовал тот, мимо которого ты уже проротировал), сервер замечает повторное использование и отзывает всё семейство токенов — превращая украденный refresh-токен в самоуничтожающуюся ловушку.

Почему это работает

«Stateless» — это спектр, а не религия. В момент, когда ты добавляешь ротацию refresh-токенов или список отзыва, ты вернул серверное состояние — и это нормально. Выигрыш JWT никогда не был «ноль серверного состояния навсегда»; он был в том, чтобы держать высокочастотную проверку (каждый API-запрос валидирует подпись локально) дешёвой, пока редкие события (логин, refresh, отзыв) трогают стор. Команды, считающие statelessness абсолютом, в итоге не могут выкинуть скомпрометированную сессию.

Где ты хранишь токен — решает, какая атака тебя достанет

Токен — bearer-учётка: кто им владеет, тот и есть ты. Поэтому хранение — решение по безопасности, а не по удобству, и это настоящий трейдофф без бесплатного варианта.

  • localStorage доступен любому JavaScript на странице. Один XSS-баг — уязвимая зависимость, несанитизированный рендер — и атакующий читает токен и выкачивает его. Аналога httpOnly для localStorage нет.
  • httpOnly-cookie невидимы для JavaScript, так что XSS их не прочтёт — но cookie отправляются автоматически, что открывает CSRF. Это закрывают через SameSite=Strict (или Lax) плюс Secure и CSRF-токены для чувствительных действий.
  • In-memory (JS-переменная) не переживает ни рефреш, ни новую вкладку, но никогда не касается диска и является наименьшей XSS-целью — частый выбор для access-токена в паре с httpOnly-cookie, держащей refresh-токен.

Хранилища, иммунного ко всему, нет; ты выбираешь, какой класс атаки тебе удобнее защищать, а короткие TTL токенов сжимают урон от того, что всё же просочится.

Выбери лучший вариант

SPA нужно держать пользователя залогиненным между рефрешами страницы. Выбери стратегию хранения токенов, которую отгрузит думающий о безопасности сеньор.

Викторина

Сервер проверяет токены через jwt.verify(token, key) и читает алгоритм из заголовка токена. Какое усиление самое важное?

Викторина

Пользователь сообщил, что его сессию угнали. Со stateless-JWT почему нельзя просто «разлогинить» его на сервере и что ограничивает урон?

Расставь шаги по порядку

Расставь проверки, которые сеньор прогоняет, чтобы усилить путь проверки JWT:

  1. 1 Зафиксируй явный allowlist алгоритмов в verify — никогда не читай alg из заголовка для выбора пути
  2. 2 Привяжи каждый ключ ровно к одному алгоритму, чтобы публичный ключ нельзя было использовать как HMAC-секрет
  3. 3 Используй HMAC-секрет из CSPRNG длиной не менее 256 бит (или асимметричную пару ключей)
  4. 4 Поставь короткие TTL access-токенов (5–15 мин), чтобы утёкший токен быстро истекал
  5. 5 Добавь ротацию refresh-токенов с детекцией повторного использования, чтобы отозвать скомпрометированное семейство токенов
Вспомните перед уходом
  1. 01
    Проведи коллегу по атаке путаницы RS256-в-HS256 и двум защитам RFC 8725, которые её останавливают.
  2. 02
    Почему у stateless-JWT нет настоящего logout и как короткие TTL плюс ротация refresh-токена сдерживают украденный токен?
Итог

Каждая ловушка JWT сводится к одной ошибке: доверять токену в описании того, как его проверять. Баг alg:none принимает пустую подпись; путаница алгоритмов превращает опубликованный публичный RS256-ключ в HMAC-секрет — обе останавливаются фиксацией явного allowlist алгоритмов в verify и привязкой каждого ключа ровно к одному алгоритму, как предписывает RFC 8725. Поскольку stateless-токен не хранится, мгновенного logout нет, поэтому украденный токен сдерживают короткими TTL access (5–15 минут) и ротацией refresh-токенов с детекцией повторного использования. HMAC-секреты должны быть минимум 256 случайных бит, чтобы их нельзя было взломать офлайн. А хранение — это трейдофф без бесплатного варианта: localStorage читается XSS, httpOnly-cookie уворачиваются от XSS, но приглашают CSRF (закрой его SameSite + Secure), а in-memory — наименьшая цель; выбери атаку, которую тебе удобнее защищать, и пусть короткие TTL сжимают то, что просочится.

Продолжить восхождение ↑JWT pitfalls: тест с выбором ответа
хоткеи развернуть
поиск
K
пред. пьеса
k
след. пьеса
j
тиры
t
это меню
?
sources4
expand
  1. 01
  2. 02
  3. 03
  4. 04

Trademarks belong to their respective owners. Editorial reference only.