Ловушки верификации JWT: алгоритмы, claims и ключи
Подпись доказывает целостность, а не то, что токен предназначен тебе. Заголовок JWT контролируется атакующим, поэтому верификатор, доверяющий его alg/kid, открывает alg:none и путаницу ключей HS256/RS256. Пин allowlist алгоритмов, проверка aud/iss/exp, JWKS по kid.
В отчёте пентеста была одна строка, угробившая весь спринт: «обход аутентификации, доступ админа, учётные данные не нужны». Приложение выпускало RS256-токены из своего IdP и верифицировало их публичным ключом IdP — по учебнику. Но JwtStrategy была настроена только с secret-or-key и больше ничем: ни algorithms, ни audience, ни issuer. Тестировщик не крал ключ. Он взял обычный токен, поменял в заголовке alg на симметричный и переподписал его, используя публичный ключ сервера как HMAC-секрет — ключ, напечатанный в discovery-документе IdP на всеобщее обозрение. Верификатор, которому не сказали, какой алгоритм ожидать, радостно пошёл по симметричному пути и подтвердил подделку. Валидная подпись была проверена. Просто это была не та подпись, для которой кому-то нужны секреты. Этот урок — про разрыв между «подпись проверилась» и «этому токену можно доверять».
Заголовок — это недоверенный ввод
JWT состоит из трёх частей: заголовок, payload, подпись. Инстинкт — обращаться со всем токеном как с запечатанным конвертом, где надо лишь проверить печать. Но заголовок — включая alg (каким алгоритмом подписано) и kid (каким ключом) — приходит до того, как ты что-либо проверил. Это контролируемый атакующим ввод. Верификатор, который читает alg из заголовка и затем проверяет «так, как говорит токен», отдал атакующему руль: он выбирает алгоритм, ты подчиняешься. Два классических обхода живут ровно в этом разрыве.
alg: none. Спецификация JWS определяет небезопасный алгоритм, буквально названный none, для случаев, где целостность гарантирована другими средствами. Токен с alg: none несёт пустую подпись. Наивный верификатор, который уважает выбор заголовка, увидит «алгоритм: none, подпись: (пусто)» и заключит, что токен валидно подписан — потому что для none пустая подпись и есть валидная. Атакующий переписывает payload на role: admin, ставит alg: none, не шлёт подпись и становится кем угодно.
Путаница ключей HS256/RS256. Это та самая из вступления, и она тоньше. RS256 асимметричен: IdP подписывает приватным ключом, ты верифицируешь парным публичным. HS256 симметричен: один и тот же секрет и подписывает, и верифицирует. Теперь представь, что твоему verify-коду передан публичный ключ RS256, но алгоритм он выбирает из заголовка. Атакующий шлёт alg: HS256. Библиотека, доверяющая заголовку, идёт по HMAC-пути — и использует переданные тобой байты (публичный ключ RS256) как HMAC-секрет. Публичный ключ публичен. У атакующего он есть. Поэтому он вычисляет валидный HS256-MAC над своим поддельным payload, и верификатор это подтверждает.
// УЯЗВИМОСТЬ: верификатор позволяет токену самому выбирать алгоритм.
// Нет allowlist `algorithms`, нет audience, нет issuer.
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
constructor() {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
secretOrKey: PUBLIC_KEY, // публичный ключ RS256 — но RS256 ничем не закреплён
// БАГ: без `algorithms` принимается токен с alg:none или HS256
// БАГ: без `audience`/`issuer` доверяется любой подписанный токен
});
}
validate(payload: any) { return { userId: payload.sub, role: payload.role }; }
}Фикс для обоих — одно и то же движение: перестань давать токену выбирать. Передай в verify явный allowlist допустимых алгоритмов. Если ожидаешь RS256, скажи algorithms: ['RS256'], и верификатор откажет none и откажет HS256 до того, как вообще посмотрит на подпись — симметричный путь и небезопасный путь для этого верификатора просто не существуют.
// THE FIX: pin the algorithm, the audience, and the issuer.
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
constructor(config: ConfigService) {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
// for an external IdP, fetch keys from its JWKS endpoint, selected by `kid`:
secretOrKeyProvider: passportJwtSecret({
cache: true,
rateLimit: true,
jwksUri: config.get('IDP_JWKS_URI'),
}),
algorithms: ['RS256'], // <- the token can no longer choose
audience: config.get('API_AUDIENCE'), // <- this token was minted for US
issuer: config.get('IDP_ISSUER'), // <- by OUR trusted IdP
// exp/nbf are validated by default unless you disable them — leave them on
});
}
validate(payload: JwtPayload) { return { userId: payload.sub, role: payload.role }; }
}▸Почему это работает
Почему верификация публичным ключом RS256 при приёме HS256-токена позволяет атакующему подделывать токены? Потому что верификатор переиспользует любые переданные ему байты ключа как вход для того алгоритма, что назвал заголовок — а для HS256 эти байты становятся HMAC-секретом. Вся безопасность HMAC держится на том, что секрет секретен. Но публичный ключ RS256 по определению опубликован — он в JWKS-документе IdP, отдаётся кому угодно. Так что «использовать публичный ключ как HMAC-секрет» означает «использовать значение, которое все уже знают, как то, что должно быть неизвестным». Любой может затем вычислить подходящий MAC над любым payload. Пин algorithms: ['RS256'] убирает HMAC-путь целиком: верификатор никогда не трактует эти байты ключа как симметричный секрет, и путаница невозможна.
Валидной подписи недостаточно
Допустим, ты запинил алгоритм и подпись честно проверяется. Доверять claims токена всё равно нельзя, потому что подпись доказывает ровно одно: байты не были подменены кем-то без ключа. Она ничего не говорит о том, когда токен валиден и для кого он был выпущен.
exp и nbf — свежесть. exp (истечение) и nbf (не ранее) ограничивают окно валидности токена. Большинство библиотек проверяют их автоматически, если ты их не отключишь (ignoreExpiration: true в passport-jwt — это грабли). Подпись остаётся валидной вечно; только exp делает так, что утёкший или вышедший из системы токен перестаёт работать. Проверяй их.
aud — audience. aud называет сервис, для которого выпущен токен. Это claim, который команды забывают, и он вызывает отказ типа confused-deputy (неавторизованное действие через делегата). Представь внутренний сервис и публичный API, которые ради удобства делят один ключ подписи (или один IdP, выпускающий для обоих). Токен, выпущенный для внутреннего сервиса, — совершенно валидный, корректно подписанный токен. Без проверки aud публичный API его принимает — предъявитель теперь владеет токеном, никогда не предназначавшимся этой поверхности. Проверка audience говорит «я принимаю только токены, чей aud — это я», и межсервисный replay умирает.
iss — issuer. iss пинит, какому IdP ты доверяешь. Если ты его пропустишь и твой набор доверенных ключей пересекается с другим issuer, токен от не той инстанции может проскочить. Проверяй, что iss — это твой IdP.
И не доверяй прикладным claims вроде roles или sub сверх того, что покрывает подпись и что заново перепроверяет твой слой авторизации — подпись гарантирует, что issuer положил эти claims туда, а не что они всё ещё отражают текущее состояние пользователя.
Вместе эти четыре проверки — exp/nbf, aud и iss — закрывают разрыв между «подпись проверилась» и «токену можно доверять». Без любой из них корректно подписанный токен не того времени, не того сервиса или не того issuer проходит насквозь.
Ключи, которые умеют ротироваться
Последняя ловушка — управление ключами. Для асимметричной верификации против внешнего IdP (Auth0, Cognito, Keycloak) не хардкодь публичный ключ. IdP ротируют ключи подписи, и захардкоженный ключ превращает рутинную ротацию в инцидент — или хуже, давит на тебя отключить верификацию, чтобы «починить». Вместо этого бери публичные ключи из JWKS-эндпоинта IdP и выбирай нужный по kid из заголовка токена, с кэшированием и путём обновления, чтобы новые ключи подхватывались. В NestJS это secretOrKeyProvider, подключённый к passportJwtSecret из jwks-rsa, с cache: true и rateLimit: true, чтобы и не долбить эндпоинт, и не закэшировать снятый ключ навсегда.
Для симметричного случая (HS256 с общим секретом) секрет несёт всю модель доверия: любой, у кого он есть, может подделать любой токен. Поэтому он должен быть высокоэнтропийным (не словарное слово, не буквальная строка "secret") и пер-окруженческим, чтобы утёкший staging-секрет не мог подделать production-токены. Слабый или утёкший HS256-секрет — это полная компрометация: нет разделения публичный/приватный, на которое можно опереться.
Тебе надо укрепить NestJS JwtStrategy, которая верифицирует access-токены, выпущенные внешним IdP (Auth0/Cognito/Keycloak). Как должна быть настроена верификация?
Приходит токен с заголовком alg:none и пустой подписью, его payload отредактирован на role:admin. Неправильно настроенный сервер принимает его как валидный. Почему — и каков однострочный фикс?
Ты запинил algorithms: ['RS256'] и подпись корректно проверяется. Почему этого всё равно недостаточно, чтобы доверять токену?
- 01Объясни два обхода JWT через заголовок (alg:none и путаница ключей HS256/RS256) и единственное движение конфигурации, что закрывает оба.
- 02Почему валидной подписи недостаточно, чтобы доверять токену, и что должен дополнительно проверять укреплённый NestJS-верификатор — включая управление ключами для внешнего IdP?
Заголовок JWT — его поля alg и kid — это контролируемый атакующим ввод, приходящий до верификации, так что верификатор, доверяющий ему, отдаёт атакующему руль. Отсюда два обхода через заголовок: alg:none, где небезопасный алгоритм спецификации делает пустую подпись «валидной», так что атакующий переписывает payload и не шлёт подпись; и путаница ключей HS256/RS256, где верификатор, держащий публичный ключ RS256, но дающий заголовку выбрать алгоритм, идёт по HMAC-пути и использует этот опубликованный публичный ключ как HMAC-секрет, позволяя любому подделать валидный MAC. Единственный фикс для обоих — запинить явный allowlist алгоритмов (algorithms: [‘RS256’]), чтобы верификатор диктовал алгоритм и отклонял ‘none’ и любой симметричный алгоритм до чтения подписи. Но валидная подпись доказывает только целостность, а не свежесть или audience, так что укреплённый верификатор должен ещё валидировать exp/nbf (оставленные включёнными по умолчанию — никогда ignoreExpiration), aud (этот токен выпущен для ЭТОГО API, закрывая межсервисный replay confused-deputy под общим ключом) и iss (от твоего доверенного IdP). Наконец, управляй ключами с прицелом на ротацию: для внешнего IdP резолвь публичный ключ из его JWKS-эндпоинта по kid с кэшированием, а не захардкоженный ключ, превращающий ротацию в инцидент; для HS256 держи общий секрет высокоэнтропийным и пер-окруженческим. Это RFC 8725, JWT BCP (Best Current Practice для JWT), и в NestJS каждая защита — одна явная опция на JwtStrategy: algorithms, audience, issuer и secretOrKeyProvider на базе jwks-rsa. Теперь, когда ты видишь JwtStrategy без опции algorithms, без audience, без issuer, — ты знаешь: перед тобой путь верификации из хука, который проверяет подпись, не задаваясь вопросами кто подписал, для кого и когда.
Практика
Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.