Перейти к содержимому
ecdsa.com
← Все статьи

Ошибки проверки JWT, которые пропускают негодные токены

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

Проверить JWT — значит выполнить не одну проверку, а шесть, и подпись — лишь одна из них. Коротко: как проверять токен, решает проверяющая сторона, а не сам токен. Закрепите алгоритм на сервере, ищите ключ среди тех, которым вы уже доверяете для этого издателя, требуйте exp, сравнивайте iss и aud точно, проверяйте typ, чтобы токен одного вида нельзя было выдать за токен другого, допускайте расхождение часов не больше минуты и кешируйте JWKS, а для неизвестных значений kid обновляйте его с ограничением частоты запросов. Дальше — о том, что ломается, если пропустить любую из этих мер; всё измерено на реальных библиотеках.

Весь вывод на этой странице получен на токенах, выпущенных специально для статьи и подписанных только что сгенерированным ключом ES256. Их проверяли jose 6.2.13 и jsonwebtoken 9.0.3 (с jwks-rsa 4.1.0) на Node.js 24.22, PyJWT 2.15.1 на Python 3.14.8 и golang-jwt 5.3.1 с keyfunc 3.8.2 на Go 1.27.3, каждая библиотека — в своём официальном образе Docker.

Что проверяет библиотека, если передать ей только ключ

Большинство ошибок с JWT — не ошибки библиотек. Это вызовы, которые проверяют подпись и на этом останавливаются, потому что вызывающий код так и не сообщил библиотеке, какой издатель, какая аудитория или какой алгоритм ожидаются. Вот что каждая библиотека сделала с тремя токенами, когда ей передали действительный открытый ключ и больше никаких параметров (без algorithms PyJWT не работает, поэтому ей передали ["ES256"]):

Токенjose jwtVerifyjsonwebtoken verifyPyJWT decodegolang-jwt Parse
Подписан нужным ключом, но с iss и aud другого сервисапринятпринятотклонён: Invalid audienceпринят
Подписан нужным ключом, утверждения exp нетпринятпринятпринятпринят
alg: "none", пустая подписьотклонёнотклонёнотклонёнотклонён

Отсюда два урока. Во-первых, все четыре библиотеки по умолчанию отклоняют неподписанные токены, так что известная проблема с none сегодня встречается в основном в самописном коде проверки. Во-вторых, токен без exp действителен вечно во всех четырёх, а три из четырёх принимают токен, выпущенный для другого сервиса, если его подписал тот же ключ. По RFC 7519, § 4.1 exp, nbf, iss и aud — необязательные утверждения (claims); библиотеки проверяют их, если они есть в токене и если проверка настроена, а всё остальное — ваша задача.

Ошибка 1: алгоритм выбирает сам токен

Заголовок alg приходит внутри токена, который вы ещё не проверили. Код проверки, который читает этот заголовок и выбирает по нему ветку обработки, позволяет отправителю самому назначать правила. RFC 8725, § 3.1, рекомендуемые практики для JWT (JWT Best Current Practices), говорит об этом прямо: библиотеки должны позволять вызывающему коду задавать поддерживаемые алгоритмы и не должны использовать никакие другие, а каждый ключ должен использоваться ровно с одним алгоритмом.

Классический случай — подмена алгоритма HS/RS (или HS/ES), описанная в RFC 8725, § 2.1; для jsonwebtoken она зарегистрирована как CVE-2015-9235. Сервис хранит один открытый ключ и проверяет токены «так, как указано в заголовке». Кто-то вычисляет HMAC от заголовка с "alg": "HS256", взяв в качестве секрета PEM-текст открытого ключа. Открытый ключ общедоступен, так что это может сделать кто угодно. Вот самописная функция проверки ровно такого устройства:

naive.mjs: антипаттерн, не выпускайте это в рабочий код
import { createHmac, timingSafeEqual, verify } from "node:crypto";
import { readFileSync } from "node:fs";

// Anti-pattern: the token picks the algorithm, one key object serves both families.
function naiveVerify(token, publicKeyPem) {
  const [h, p, s] = token.split(".");
  const { alg } = JSON.parse(Buffer.from(h, "base64url"));
  const input = Buffer.from(`${h}.${p}`);
  const sig = Buffer.from(s, "base64url");
  if (alg === "HS256") {
    const mac = createHmac("sha256", publicKeyPem).update(input).digest();
    return mac.length === sig.length && timingSafeEqual(mac, sig);
  }
  if (alg === "ES256") {
    return verify("sha256", input, { key: publicKeyPem, dsaEncoding: "ieee-p1363" }, sig);
  }
  throw new Error(`unsupported alg ${alg}`);
}

const pem = readFileSync("out/public.pem", "utf8");
const tokens = JSON.parse(readFileSync("out/tokens.json", "utf8"));
console.log("ES256 token from the issuer:", naiveVerify(tokens.ok, pem));
console.log("HS256 token keyed with the public PEM:", naiveVerify(tokens["HS256 keyed with the public PEM"], pem));
Вывод (node:24.22.0-bookworm-slim)
ES256 token from the issuer: true
HS256 token keyed with the public PEM: true

Оба вызова возвращают true. Второй токен создан без закрытого ключа. Обратите внимание: сравнение с постоянным временем выполнения здесь не помогает — код внимательно относится ко времени выполнения и всё равно ошибается в том, чему доверять.

Исправление — фиксированный список разрешённых алгоритмов на стороне проверки, в идеале из одного значения, и ключи, к которым привязан их алгоритм. Получив algorithms: ["ES256"], все четыре библиотеки отклонили поддельный токен, каждая со своим сообщением: jose — "alg" (Algorithm) Header Parameter value not allowed, jsonwebtoken — invalid algorithm, PyJWT — The specified alg value is not allowed, golang-jwt — signing method HS256 is invalid. У них есть и вторая линия защиты, поэтому сегодня такая подмена в коде библиотек встречается редко:

  • jsonwebtoken 9.x (начиная с 9.0.0, декабрь 2022 года) выводит разрешённые алгоритмы из типа ключа, если algorithms не указан: для ключа EC допустимы только ES256, ES384 и ES512.
  • PyJWT отказывается использовать открытый ключ или сертификат в формате PEM как секрет HMAC: The specified key is an asymmetric key or x509 certificate and should not be used as an HMAC secret.
  • golang-jwt проверяет тип ключа в Go: методу HMAC нужен []byte, поэтому *ecdsa.PublicKey отклоняется с ошибкой key is of invalid type.
  • jose проверяет, что тип ключа подходит к алгоритму. Даже без параметра algorithms проверка поддельного токена открытым ключом EC завершилась ошибкой KeyObject instances must be of type "secret" for the HS256 algorithm.

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

Ошибка 2: самописный код принимает «none»

RFC 7518, § 3.6 регистрирует "alg": "none" для токенов, целостность которых защищена чем-то другим. В таблице выше все библиотеки по умолчанию его отклонили. Риск кроется в коде вокруг библиотеки: «режим отладки», который пропускает проверку, ветка, которая считает токен с пустой подписью «ещё не подписанным», или список запрещённых значений, который сравнивает alg с учётом регистра. Новая редакция BCP, draft-ietf-oauth-rfc8725bis (на октябрь 2026 года она в очереди RFC Editor), указывает именно на это: список запрещённых значений с none, в обход которого проходит заголовок со значением noNE. У списка разрешённых значений такой лазейки нет.

Ошибка 3: доверие к kid, jku, x5u или встроенному jwk

kid нужен, чтобы выбрать один из нескольких ключей, которым вы уже доверяете. Это не ссылка на ключ, который предоставляет сам токен. RFC 8725, § 3.10 предписывает считать полученные заголовки недоверенными входными данными:

  • Ищите kid в словаре, а не в запросе к базе и не в пути к файлу. kid, который попадает в SQL, LDAP или имя файла, становится точкой инъекции. Библиотеки JWKS из этой статьи только сравнивают его со значениями kid ключей, загруженных с URL из вашей конфигурации, и это правильный подход.
  • Игнорируйте jku и x5u. Оба заголовка — URL внутри токена. Загружая ключи по ним, вы позволяете отправителю выбирать, куда подключается ваш сервер и какие ключи он использует. Вместо этого задайте URL JWKS для каждого издателя; если без этих заголовков совсем не обойтись, RFC 8725 предлагает сверять такие URL со списком разрешённых.
  • Никогда не проверяйте токен доступа по jwk или x5c, встроенному в его собственный заголовок. Токен, подписанный ключом, который он сам несёт, доказывает лишь то, что у отправителя есть какой-то ключ. Протоколы, которые действительно используют встроенный ключ, например доказательства DPoP, привязывают его к чему-то ещё (к утверждению cnf токена доступа). Подробнее — в статье Что такое DPoP.
  • Явно обрабатывайте отсутствие kid. Если в наборе несколько ключей, а токен не указывает ни один, jwks-rsa не пытается угадать (No KID specified and JWKS endpoint returned more than 1 key); самописный поиск, который возвращает «первый ключ», после ротации молча выберет не тот.

Ошибка 4: aud и iss не проверяются или проверяются нестрого

Поставщик удостоверений (identity provider, IdP) обычно подписывает токены для множества клиентов и API одним и тем же ключом. Если ваш API не проверяет aud, токен, выпущенный для любого другого приложения того же поставщика, работает и у вас — это первая строка таблицы. RFC 8725, § 3.9 требует, чтобы получатель отклонял JWT, если аудитория в нём не указана или указывает не на этого получателя, а RFC 9068, § 4 требует точного совпадения iss для токенов доступа OAuth.

На практике важны три детали:

  • Точное сравнение строк. aud и iss — строки, чувствительные к регистру, и https://id.example.com — не то же самое, что https://id.example.com/. Берите значение издателя из документа обнаружения поставщика, а не из первого попавшегося токена.
  • aud может быть массивом. Библиотеки принимают токен, если совпадает любой элемент. По RFC 7519 это правильно, но означает, что токен для ["https://api.example.com", "https://admin.example.com"] работает в обоих API. Если ваш API должен принимать только токены, выпущенные исключительно для него, в PyJWT есть options={"strict_aud": True}; в остальных библиотеках проверяйте длину массива сами.
  • Ключи должны принадлежать издателю. RFC 8725, § 3.8: если в токене есть iss, ключ, которым он проверен, должен быть ключом этого издателя. Если издателей несколько, держите по одному клиенту JWKS на каждого и выбирайте клиент по конфигурации и никогда — лишь по непроверенному iss из самого токена. У мультитенантных поставщиков, которые используют один набор ключей для всех тенантов (типичный пример — Microsoft Entra ID), только проверка iss отделяет ваш тенант от всех остальных; подробно об этом — в статье Проверка ID-токена OIDC.

Ошибка 5: токены без exp и щедрый допуск по времени

Библиотека проверяет exp, если оно есть. Токен без него приняли все четыре библиотеки. Если ваш издатель хоть раз забудет добавить это утверждение или кто-то найдёт в коде путь, который выпускает токены без него, срок действия этих токенов не истечёт никогда. Сделайте exp обязательным: requiredClaims в jose, options={"require": [...]} в PyJWT, jwt.WithExpirationRequired() в golang-jwt и явная проверка в jsonwebtoken, где такого параметра нет.

Расхождение часов действительно бывает, но оно невелико. RFC 7519, § 4.1.4 разрешает «небольшой допуск, обычно не более нескольких минут». Для серверов, часы которых синхронизирует NTP, хватает от 30 до 60 секунд. Допуск в час, который часто добавляют, чтобы прошёл нестабильный тест, продлевает срок жизни каждого токена на час, в том числе тех токенов, которые вы хотели отсечь. Параметр называется clockTolerance в jose и jsonwebtoken (в секундах), leeway в PyJWT и jwt.WithLeeway в golang-jwt. Тот же допуск действует и для nbf.

Если проверка токена, который «должен» быть действительным, завершается ошибкой истечения срока действия или ошибкой «ещё не действителен», сначала сравните часы сервера с часами издателя и только потом трогайте допуск. Контейнер или виртуальная машина, которые вышли из приостановки с отставшими часами, дают ровно те ошибки jwt not active и "nbf" claim timestamp check failed, что видны в выводе ниже.

Ошибка 6: принимается токен не того типа

Поставщик OpenID Connect подписывает ID-токены и токены доступа одним и тем же ключом, часто с тем же iss, а иногда и с пересекающимися aud. API, который проверяет только подпись, издателя и аудиторию, может принять ID-токен как токен доступа. RFC 8725, § 3.11 рекомендует явную типизацию, а RFC 9068 делает её обязательной для токенов доступа в формате JWT: заголовок typ должен иметь значение at+jwt (или application/at+jwt), а серверы ресурсов должны отклонять любое другое значение. Если ваш поставщик выпускает токены по RFC 9068, проверяйте typ; если нет — проверяйте утверждение, которое есть только у токенов доступа, например scope или client_id.

Ошибка 7: decode вместо verify

В каждой библиотеке есть функция, которая разбирает токен без проверки: jwt.decode() в jsonwebtoken, decodeJwt() в jose, jwt.decode(..., options={"verify_signature": False}) в PyJWT, ParseUnverified в golang-jwt. Они нужны для маршрутизации (прочитать iss, чтобы выбрать клиент JWKS) и для отладки. На ревью кода любое их использование на пути обработки запроса следует считать подозрительным, а любое решение на основе их результата, кроме выбора того, какую функцию проверки вызвать дальше, — ошибкой.

Ошибка 8: JWKS загружается при каждом запросе или не обновляется никогда

Если загружать набор ключей при каждом запросе, к каждому вызову добавляется ещё одно сетевое обращение, а доступность поставщика удостоверений становится вашей доступностью. Если кешировать набор навсегда, первая же ротация ключей ломает все запросы до перезапуска. Правильное поведение, которое реализуют клиенты JWKS из таблицы ниже, — кеш с ограниченным временем жизни плюс обновление, когда токен указывает неизвестный kid, причём с ограничением частоты запросов на такое обновление, чтобы поток токенов со случайными значениями kid не превратил ваш API в генератор нагрузки на поставщика.

КлиентВремя жизни кеша (по умолчанию)Неизвестный kidТайм-аут загрузки
jose createRemoteJWKSet10 минут (cacheMaxAge)повторная загрузка, не чаще раза в 30 с (cooldownDuration)5 с
jwks-rsa 4.1.010 минут на ключ, 5 ключейповторная загрузка при каждом промахе; rateLimit выключен, пока его не включат (тогда 10 в минуту)30 с
PyJWT PyJWKClient300 с (lifespan)повторная загрузка, не чаще раза в 30 с (cooldown_duration)30 с
keyfunc v3 NewDefaultфоновое обновление каждый часповторная загрузка, не чаще раза в 5 минут1 минута

Значения — настройки по умолчанию в версиях, перечисленных в начале статьи; они взяты из исходного кода. Разница измерима: 20 токенов со случайными значениями kid вызвали 20 загрузок набора ключей у jwks-rsa с настройками по умолчанию, 10 загрузок с rateLimit: true (остальные 10 токенов сразу отклонены с ошибкой JwksRateLimitError) и всего одну загрузку у jose. Создавайте клиент один раз для каждого издателя и используйте его повторно: у клиента JWKS, созданного внутри обработчика запроса, кеш каждый раз пуст. Сама ротация со стороны издателя разобрана в статье Ротация ключей JWKS без простоя.

Правильная проверка в четырёх библиотеках

Четыре реализации проверки ниже делают одно и то же: только ES256, ключи с URL JWKS издателя, точные iss и aud, обязательное exp, typ: at+jwt, допуск 30 секунд. Каждую прогнали на одних и тех же 8 токенах.

verify-jose.mjs (jose 6.2.13)
import { createRemoteJWKSet, jwtVerify } from "jose";

// Created once per issuer and reused: caches the key set, re-fetches it
// when a token names an unknown kid (at most once per cooldown).
const JWKS = createRemoteJWKSet(new URL(process.env.JWKS_URL), {
  cacheMaxAge: 600_000, // re-fetch at least every 10 minutes
  cooldownDuration: 30_000, // at most one kid-miss re-fetch per 30 s
});

export async function verifyAccessToken(token) {
  const { payload } = await jwtVerify(token, JWKS, {
    algorithms: ["ES256"], // fixed by the verifier, never taken from the token
    issuer: "https://id.example.com",
    audience: "https://api.example.com",
    typ: "at+jwt", // RFC 9068 access token, not an ID token
    requiredClaims: ["exp", "sub"], // exp is only checked when present
    clockTolerance: 30, // seconds
  });
  return payload;
}
verify-jsonwebtoken.mjs (jsonwebtoken 9.0.3, jwks-rsa 4.1.0)
import jwt from "jsonwebtoken";
import jwksClient from "jwks-rsa";

const client = jwksClient({
  jwksUri: process.env.JWKS_URL,
  cache: true, // per-kid cache, 10 minutes by default
  rateLimit: true, // off by default; caps JWKS fetches at 10 per minute
});

const getKey = (header, callback) =>
  client.getSigningKey(header.kid, (err, key) => callback(err, key?.getPublicKey()));

export function verifyAccessToken(token) {
  const options = {
    algorithms: ["ES256"],
    issuer: "https://id.example.com",
    audience: "https://api.example.com",
    clockTolerance: 30,
    complete: true, // also return the header, to check typ
  };
  return new Promise((resolve, reject) =>
    jwt.verify(token, getKey, options, (err, decoded) => {
      if (err) return reject(err);
      if (decoded.header.typ !== "at+jwt") return reject(new Error("unexpected typ"));
      if (typeof decoded.payload.exp !== "number") return reject(new Error("exp is required"));
      resolve(decoded.payload);
    }),
  );
}
verify_pyjwt.py (PyJWT 2.15.1, cryptography 50.0.2)
import os

import jwt

# One client per issuer, reused across requests: caches the key set and
# re-fetches on an unknown kid (at most once per cooldown).
jwks = jwt.PyJWKClient(os.environ["JWKS_URL"], lifespan=600)


def verify_access_token(token: str) -> dict:
    signing_key = jwks.get_signing_key_from_jwt(token)
    decoded = jwt.decode_complete(
        token,
        signing_key,
        algorithms=["ES256"],  # required argument in PyJWT 2.x
        issuer="https://id.example.com",
        audience="https://api.example.com",
        leeway=30,
        options={"require": ["exp", "iss", "aud", "sub"]},
    )
    if decoded["header"].get("typ") != "at+jwt":
        raise jwt.InvalidTokenError("unexpected typ")
    return decoded["payload"]
verify.go (golang-jwt v5.3.1, keyfunc v3.8.2)
package main

import (
	"context"
	"errors"
	"time"

	"github.com/MicahParks/keyfunc/v3"
	"github.com/golang-jwt/jwt/v5"
)

type Verifier struct {
	jwks   keyfunc.Keyfunc
	parser *jwt.Parser
}

func NewVerifier(ctx context.Context, jwksURL string) (*Verifier, error) {
	// Background refresh every hour; an unknown kid triggers at most
	// one extra fetch per 5 minutes (keyfunc defaults).
	jwks, err := keyfunc.NewDefaultCtx(ctx, []string{jwksURL})
	if err != nil {
		return nil, err
	}
	parser := jwt.NewParser(
		jwt.WithValidMethods([]string{"ES256"}),
		jwt.WithIssuer("https://id.example.com"),
		jwt.WithAudience("https://api.example.com"),
		jwt.WithExpirationRequired(),
		jwt.WithLeeway(30*time.Second),
	)
	return &Verifier{jwks: jwks, parser: parser}, nil
}

func (v *Verifier) VerifyAccessToken(raw string) (jwt.MapClaims, error) {
	claims := jwt.MapClaims{}
	token, err := v.parser.ParseWithClaims(raw, claims, v.jwks.Keyfunc)
	if err != nil {
		return nil, err
	}
	if token.Header["typ"] != "at+jwt" {
		return nil, errors.New("unexpected typ")
	}
	return claims, nil
}

Тестовые токены: один действительный токен и 7 токенов, каждый из которых нарушает ровно одно правило. JWKS отдавался по HTTP изнутри контейнера, и каждая библиотека загружала его через свой клиент JWKS.

Вывод: jose и jsonwebtoken (node:24.22.0-bookworm-slim)
--- jose
ok                                 ACCEPTED sub=user-42
aud for another API                ERR_JWT_CLAIM_VALIDATION_FAILED: unexpected "aud" claim value
no exp                             ERR_JWT_CLAIM_VALIDATION_FAILED: missing required "exp" claim
expired 1 h ago                    ERR_JWT_EXPIRED: "exp" claim timestamp check failed
nbf in 1 h                         ERR_JWT_CLAIM_VALIDATION_FAILED: "nbf" claim timestamp check failed
typ JWT (an ID token)              ERR_JWT_CLAIM_VALIDATION_FAILED: unexpected "typ" JWT header value
unknown kid                        ERR_JWKS_NO_MATCHING_KEY: no applicable key found in the JSON Web Key Set
HS256 keyed with the public PEM    ERR_JOSE_ALG_NOT_ALLOWED: "alg" (Algorithm) Header Parameter value not allowed
--- jsonwebtoken
ok                                 ACCEPTED sub=user-42
aud for another API                JsonWebTokenError: jwt audience invalid. expected: https://api.example.com
no exp                             Error: exp is required
expired 1 h ago                    TokenExpiredError: jwt expired
nbf in 1 h                         NotBeforeError: jwt not active
typ JWT (an ID token)              Error: unexpected typ
unknown kid                        JsonWebTokenError: error in secret or public key callback: Unable to find a signing key that matches '2026-09-z'
HS256 keyed with the public PEM    JsonWebTokenError: invalid algorithm
Вывод: PyJWT (python:3.14.8-slim-trixie) и golang-jwt (golang:1.27.3-trixie)
--- PyJWT
ok                                 ACCEPTED sub=user-42
aud for another API                InvalidAudienceError: Audience doesn't match
no exp                             MissingRequiredClaimError: Token is missing the "exp" claim
expired 1 h ago                    ExpiredSignatureError: Signature has expired
nbf in 1 h                         ImmatureSignatureError: The token is not yet valid (nbf)
typ JWT (an ID token)              InvalidTokenError: unexpected typ
unknown kid                        PyJWKClientError: Unable to find a signing key that matches: "2026-09-z"
HS256 keyed with the public PEM    InvalidAlgorithmError: The specified alg value is not allowed
--- golang-jwt v5 + keyfunc v3
ok                                 ACCEPTED sub=user-42
aud for another API                token has invalid claims: token has invalid audience
no exp                             token has invalid claims: token is missing required claim: exp claim is required
expired 1 h ago                    token has invalid claims: token is expired
nbf in 1 h                         token has invalid claims: token is not valid yet
typ JWT (an ID token)              unexpected typ
unknown kid                        token is unverifiable: error while executing keyfunc: key not found "2026-09-z"
failed keyfunc: could not read JWK from storage
HS256 keyed with the public PEM    token signature is invalid: signing method HS256 is invalid

Для большинства приведённых выше сообщений библиотек в каталоге ошибок есть своя страница с причинами и способами исправления, например unexpected "aud" claim value, jwt not active и no applicable key found in the JSON Web Key Set.

Чек-лист

  1. Одна конфигурация проверки на каждого издателя, собранная при запуске: URL JWKS, издатель, аудитория, список алгоритмов.
  2. Алгоритмы: фиксированный список разрешённых, обычно из одного значения. Никогда не берётся из токена и никогда не содержит none.
  3. Ключи: только из JWKS или файла ключа, заданных в конфигурации; kid служит ключом поиска в словаре; jku, x5u и встроенный jwk для токенов доступа игнорируются.
  4. Утверждения: exp обязательно; iss и aud сравниваются точно; nbf учитывается; допуск от 30 до 60 секунд.
  5. Тип токена: проверяется typ (at+jwt для токенов доступа по RFC 9068) или другое утверждение, которое отличает токены доступа от ID-токенов.
  6. Кеш JWKS: один клиент, который используется повторно; обновление по времени; обновление при неизвестном kid с ограничением частоты запросов.
  7. Никакого decode без проверки на пути обработки запроса.
  8. Тесты: один действительный токен и по одному токену на каждое правило выше, причём проверка каждого из последних должна завершаться ошибкой. Для начала подойдут 8 токенов из этой статьи.

Источники