Проверить 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 jwtVerify | jsonwebtoken verify | PyJWT decode | golang-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-текст открытого ключа. Открытый ключ общедоступен, так что это может сделать кто угодно. Вот самописная функция проверки ровно такого устройства:
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));
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 createRemoteJWKSet | 10 минут (cacheMaxAge) | повторная загрузка, не чаще раза в 30 с (cooldownDuration) | 5 с |
| jwks-rsa 4.1.0 | 10 минут на ключ, 5 ключей | повторная загрузка при каждом промахе; rateLimit выключен, пока его не включат (тогда 10 в минуту) | 30 с |
PyJWT PyJWKClient | 300 с (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 токенах.
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;
}
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);
}),
);
}
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"]
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
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
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.
Чек-лист
- Одна конфигурация проверки на каждого издателя, собранная при запуске: URL JWKS, издатель, аудитория, список алгоритмов.
- Алгоритмы: фиксированный список разрешённых, обычно из одного значения. Никогда не берётся из токена и никогда не содержит
none. - Ключи: только из JWKS или файла ключа, заданных в конфигурации;
kidслужит ключом поиска в словаре;jku,x5uи встроенныйjwkдля токенов доступа игнорируются. - Утверждения:
expобязательно;issиaudсравниваются точно;nbfучитывается; допуск от 30 до 60 секунд. - Тип токена: проверяется
typ(at+jwtдля токенов доступа по RFC 9068) или другое утверждение, которое отличает токены доступа от ID-токенов. - Кеш JWKS: один клиент, который используется повторно; обновление по времени; обновление при неизвестном
kidс ограничением частоты запросов. - Никакого
decodeбез проверки на пути обработки запроса. - Тесты: один действительный токен и по одному токену на каждое правило выше, причём проверка каждого из последних должна завершаться ошибкой. Для начала подойдут 8 токенов из этой статьи.
Источники
- RFC 7519: JSON Web Token, § 4.1 (зарегистрированные утверждения), § 7.2 (проверка)
- RFC 7518: JSON Web Algorithms, § 3.6 (
none) - RFC 8725: рекомендуемые практики для JSON Web Token, § 2.1, § 3.1, §§ 3.8–3.11
- draft-ietf-oauth-rfc8725bis, обновление RFC 8725
- RFC 9068: профиль JWT для токенов доступа OAuth 2.0, § 2.1 и § 4
- Документация jose:
jwtVerify,createRemoteJWKSet - README jsonwebtoken и jwks-rsa
- Документация PyJWT: использование,
PyJWKClient - golang-jwt/jwt v5 и keyfunc v3