Безопасность
Ловушки безопасности JWT: путаница alg, отсутствие отзыва и где утекают токены
Пентестер логинится как обычный пользователь, копирует 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:
- Атакующий берёт публичный ключ (он публичный).
- Атакующий подделывает токен с
"alg":"HS256"и подписывает его HMAC-SHA256, используя строку публичного ключа как HMAC-секрет. - Сервер читает
HS256, уходит в HMAC-ветку кода и выполняет HMAC-SHA256 со своим публичным ключом в роли секрета — ровно ту операцию, что только что сделал атакующий. - Подписи совпадают. Поддельный 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-токена |
Токен в localStorage | XSS читает токен через 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 Зафиксируй явный allowlist алгоритмов в verify — никогда не читай alg из заголовка для выбора пути
- 2 Привяжи каждый ключ ровно к одному алгоритму, чтобы публичный ключ нельзя было использовать как HMAC-секрет
- 3 Используй HMAC-секрет из CSPRNG длиной не менее 256 бит (или асимметричную пару ключей)
- 4 Поставь короткие TTL access-токенов (5–15 мин), чтобы утёкший токен быстро истекал
- 5 Добавь ротацию refresh-токенов с детекцией повторного использования, чтобы отозвать скомпрометированное семейство токенов
- 01Проведи коллегу по атаке путаницы RS256-в-HS256 и двум защитам RFC 8725, которые её останавливают.
- 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 сжимают то, что просочится.