Перейти к основному содержимому

JWT — семь строк, которые обходят авторизацию

Разработчикам API

На первый взгляд код выглядит аккуратно — импорт библиотеки, секрет из переменной окружения, одна функция проверки:

import jwt from "jsonwebtoken";

const KEY = process.env.KEY;

export function verifyToken(token) {
return jwt.verify(token, KEY);
}

Семь строк, типичный фрагмент middleware для Express или Fastify. Код компилируется, проходит ревью («секрет в env, verify вместо decode — молодцы») и уезжает в прод.

Проблема в том, что jwt.verify(token, KEY) доверяет заголовку токена alg, если вы явно не перечислили допустимые алгоритмы. При удачной эксплуатации атакующий подставляет свой payload (sub, role, admin: true) и проходит проверку без знания секрета.

Структура JWT, cookie/session и OAuth — в аутентификации и авторизации. Здесь разбираем типичные ошибки проверки на Node.js с пакетом jsonwebtoken.


Почему «просто verify» недостаточно

JWT состоит из header.payload.signature. Поле alg в header говорит серверу, как проверять подпись — HMAC (HS256), RSA (RS256), ECDSA и т.д.

Без опции { algorithms: [...] } библиотека берёт алгоритм из токена и подбирает способ проверки под переданный ключ. Это открывает два класса атак.

Ошибка в кодеЧто делает атакующийИтог
Нет whitelist algorithmsПодменяет alg и пересчитывает подписьЛюбой sub / роль в payload
KEY — RSA публичный ключ, алгоритм не зафиксированМеняет RS256HS256, подписывает HMAC тем же PEM-текстомПодделка без приватного ключа
const KEY = process.env.KEY при загрузке модуляЖдёт, пока KEY === undefined (dotenv позже, тест без env)Непредсказуемое поведение, «работает локально»
Секрет не проверяют при стартеПустой или дефолтный ключ в .env.exampleBrute-force или угадывание
Критично

Успешная эксплуатация даёт полный обход аутентификации: атакующий выдаёт себя за любого пользователя, подставляя нужный sub и claims в payload.


Атака с alg: none

none — алгоритм «без подписи». Токен из двух сегментов и пустой третьей части:

eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJzdWIiOiJhZG1pbiIsInJvbGUiOiJhZG1pbiJ9.

Расшифровка header:

{"alg": "none", "typ": "JWT"}

Payload — любой JSON с нужным sub, role, exp.

В старых версиях jsonwebtoken (до жёстких дефолтов ~2015–2018) такой токен проходил verify, если разработчик не указал algorithms. Библиотека видела alg: none и не требовала подписи.

Современные версии (v9+) по умолчанию отклоняют none, пока вы сами не добавите его в whitelist — сообщение вроде please specify "none" in "algorithms". Это не повод расслабляться: достаточно скопировать из Stack Overflow { algorithms: ['HS256', 'none'] } «на всякий случай», и дыра вернётся.

Защита — явный список только тех алгоритмов, которые реально использует ваш IdP:

jwt.verify(token, KEY, { algorithms: ["HS256"] });

Подмена алгоритма RS256 → HS256

Классическая algorithm confusion (CVE-2015-9235, затронуты многие JWT-библиотеки).

Сценарий:

  1. Сервер подписывает access-токены RSA (приватный ключ только у auth-сервиса).
  2. Resource server для проверки хранит публичный PEM в process.env.KEY — это нормальная схема.
  3. Код снова jwt.verify(token, KEY) без algorithms.

Атакующий:

  1. Берёт публичный ключ (он часто лежит в /.well-known/jwks.json, в репозитории, в Docker-образе).
  2. Собирает header с "alg": "HS256".
  3. Пишет payload с "sub": "1", "role": "admin".
  4. Считает HMAC-SHA256 от base64url(header).base64url(payload), используя строку публичного PEM как симметричный секрет.
  5. Отправляет подделанный токен в Authorization: Bearer ….

Сервер видит HS256, переданный «секрет» совпадает с PEM — проверка проходит. Приватный ключ атакующему не нужен.

Симметричный и асимметричный ключ — разные ветки

Для HS256 передавайте общий секрет и { algorithms: ["HS256"] }. Для RS256 — только публичный ключ (или JWKS) и { algorithms: ["RS256"] }. Никогда не смешивайте «один KEY на всё» без явного whitelist.


Секрет на уровне модуля и пустой KEY

Вторая ловушка в тех же семи строках — const KEY = process.env.KEY при импорте файла.

// app.js
import dotenv from "dotenv";
import { verifyToken } from "./auth.js"; // KEY уже прочитан — undefined

dotenv.config(); // env появился слишком поздно

Node кэширует модули: KEY остаётся undefined на всё время процесса. Локально «всё ок», если env задан в shell до node, а в Docker/K8s без переменной — тихий сбой или странные ошибки verify.

Типичные симптомы:

  • в prod «401 на всех запросах», пока кто-то не добавит env;
  • в dev токены «магически» проходят из-за другого порядка загрузки;
  • тесты мокают env после первого import — проверка идёт с чужим ключом.

Правило — читать секрет внутри функции или после гарантированной инициализации конфига; при старте приложения падать, если секрет пуст:

function getKey() {
const key = process.env.JWT_SECRET;
if (!key) {
throw new Error("JWT_SECRET is not configured");
}
return key;
}

Безопасная проверка — эталон

Минимально приемлемый вариант для симметричного HS256:

import jwt from "jsonwebtoken";

export function verifyToken(token) {
const secret = process.env.JWT_SECRET;
if (!secret) {
throw new Error("JWT_SECRET is not configured");
}

return jwt.verify(token, secret, {
algorithms: ["HS256"],
issuer: "https://auth.example.com",
audience: "my-api",
clockTolerance: 5,
});
}

Для RS256 через JWKS (Keycloak, Auth0, Cognito) — algorithms: ["RS256"], ключ из JWKS endpoint, те же проверки iss / aud / exp.

ПроверкаЗачем
algorithms: [...]Запрет none и подмены RS256↔HS256
issuer / audienceТокен выдан вашим IdP и для вашего API
exp, nbfОтсечь просроченные и «из будущего»
Секрет при стартеFail fast вместо «verify с undefined»
jwt.verify, не jwt.decodeDecode не проверяет подпись
Фреймворки

Spring oauth2ResourceServer, Nest @nestjs/jwt с явным algorithms, Fastify @fastify/jwt — всё это задаёт whitelist за вас, если конфиг не ослаблен вручную. Сырой jsonwebtoken в middleware — зона повышенного риска.


Чек-лист перед продом

  • В jwt.verify указан единственный ожидаемый алгоритм (HS256 или RS256, не оба «на всякий случай»).
  • В whitelist нет none.
  • Секрет / JWKS URI проверяются при старте приложения.
  • Env загружается до первого import модуля с verify (или ключ читается лениво).
  • Проверяются iss, aud, exp (и при необходимости sub против своей БД).
  • Нигде в коде нет jwt.decode там, где нужна авторизация.
  • Публичный RSA-ключ не используется как HMAC-секрет — разные конфиги для sign и verify.

Связанные материалы

ТемаСтатья
JWT, session, PASETO, OAuthАутентификация и авторизация
Broken Access Control, IDOR8.07 / 128 — каталог угроз
Rate limit на APIRate limiting
Триада CIAОсновы информационной безопасности

См. также

Другие статьи этого же раздела в боковом меню (как на странице "О разделе").