← назад к разделу

В маркетплейсе сервер авторизации выдаёт покупателю access token, а сервисы заказов, платежей и поддержки принимают его и решают, кто пришёл. Подпись JWT держит всю эту схему: если сервис заказов можно убедить принять токен, который не подписывал сервер авторизации, покупатель становится администратором, а продавец видит чужие выплаты. Ошибка проверки подписи описана как CWE-347 «Improper Verification of Cryptographic Signature», а сводный список того, что ломают на практике, и мер против этого дан в RFC 8725 «JSON Web Token Best Current Practices» (BCP 225, февраль 2020). Ниже разобраны атаки из него, как они выглядят в коде и что проверять на ревью.

Обязательно

Как устроен JWT и почему заголовок пишет атакующийспросят на собеседовании

Подписанный JWT - это JWS в компактной форме. RFC 7515, раздел 3.1 задаёт её как три куска через точку: base64url от заголовка, base64url от полезной нагрузки (claims) и base64url от подписи. Подпись считается над первыми двумя кусками вместе с точкой между ними, это и есть «signing input».

Заголовок говорит, как токен подписан и чем его проверять. Поля, о которых пойдёт речь, все из RFC 7515, раздел 4.1:

  • alg (4.1.1) - алгоритм подписи, единственное обязательное поле: «MUST be present».
  • kid (4.1.4) - подсказка, каким ключом подписано.
  • jku (4.1.2) и x5u (4.1.5) - адрес, откуда скачать набор ключей или сертификат.
  • jwk (4.1.3) - сам открытый ключ, вложенный прямо в заголовок.
  • typ (4.1.9) - что это за объект, чтобы приложение отличало один вид токена от другого.

В claims лежит то, ради чего токен выпущен. Зарегистрированные имена заданы в RFC 7519, раздел 4.1:

  • iss (4.1.1) - кто выпустил, на маркетплейсе это адрес сервера авторизации.
  • sub (4.1.2) - о ком токен: buyer-17, seller-42.
  • aud (4.1.3) - для кого: orders-service, payments-service.
  • exp (4.1.4) - момент, после которого токен не принимают.

Остальное, например роли, приложение добавляет само. Ни одно из полей заголовка и claims, кроме alg, стандарт не делает обязательным, поэтому обязательность exp и aud - решение проверяющего.

base64url - это не шифрование. Покупатель маркетплейса, получив свой access token, раскодирует его в любой консоли, увидит "sub": "buyer-17" и может переписать что угодно: заголовок, роль, срок. Помешать ему должна только подпись.

И тут ловушка курицы и яйца. Чтобы проверить подпись, надо знать алгоритм и ключ. Если взять их из заголовка, то решение «как проверять» принимается по данным, которые ещё не проверены и которые прислал атакующий. Подпись защищает заголовок только тогда, когда алгоритм и ключ проверяющий уже выбрал сам. Поэтому RFC 7515, раздел 5.2 (и вслед за ним RFC 8725, раздел 3.2) оставляет выбор приложению: если алгоритм токена приложению не подходит, токен «SHOULD consider ... to be invalid», даже когда подпись формально сошлась.

Отсюда простое правило чтения всех атак ниже: всё, что пришло в токене, до проверки подписи - пользовательский ввод, такой же, как тело запроса. Заголовок пишет атакующий, claims пишет атакующий, и только после проверки подписи заранее выбранным ключом claims становятся словами сервера авторизации.

Как устроена атака: алгоритм и ключ выбирает токенспросят на собеседовании

Главная ловушка в том, что алгоритм подписи записан в самом заголовке, в поле alg, а значит, его выбирает тот, кто прислал токен.

alg: none

RFC 8725, раздел 2.1 описывает первую атаку прямо: «The algorithm can be changed to "none" by an attacker, and some libraries would trust this value and "validate" the JWT without checking any signature». Покупатель берёт свой токен, меняет в заголовке alg на none, в claims дописывает "role": "admin" и отрезает подпись. Проверка, которая смотрит на alg из токена, видит «подписи нет и не должно быть» и пропускает. Шпаргалка OWASP по JWT формулирует меру одной строкой: «Make sure that 'alg':'none' is not accepted by your JWT parser».

Что получает атакующий: любые claims без единого секрета. На маркетплейсе это роль администратора, sub другого покупателя (его заказы, адреса, возвраты) или sub продавца с доступом к выплатам. Для атаки не нужно ничего, кроме собственного токена и текстового редактора.

Что говорит RFC 8725: алгоритм выбирает вызывающий код, библиотека «MUST NOT use any other algorithms» (раздел 3.1), а none библиотеки «SHOULD NOT consume ... unless explicitly requested by the caller» (раздел 3.2). Тот же раздел оговаривает, что none уместен только там, где токен защищён другими средствами. У access token, который ходит от браузера к API, таких средств нет.

Подмена RS256 на HS256

Вторая атака из того же раздела тоньше. Сервер авторизации подписывает токены RS256: закрытым ключом подписывает, а сервисы проверяют открытым, который опубликован в JWKS и доступен всем. Атакующий меняет alg на HS256 и подписывает токен HMAC, взяв в качестве секрета текст этого открытого ключа. Библиотека, которая выбирает алгоритм по заголовку, проверяет HMAC тем же «ключом», что ей настроили, то есть открытым RSA-ключом, и подпись сходится: по словам RFC, «using the RSA public key as the HMAC shared secret». Так была устроена CVE-2015-9235 в Node-пакете jsonwebtoken до версии 4.2.2.

Простыми словами: асимметричная схема держится на том, что подписать может только владелец закрытого ключа. HMAC симметричен, им подписывают и проверяют одним и тем же секретом. Если проверяющий согласился считать открытый ключ секретом HMAC, то «секрет» опубликован на /.well-known/jwks.json, и подписать токен может любой.

Что получает атакующий: то же, что при none, но против сервиса, который none уже запретил. Сервис заказов честно проверяет подпись, подпись честно сходится, а токен выпустил покупатель.

Что говорит RFC 8725: «each key MUST be used with exactly one algorithm, and this MUST be checked when the cryptographic operation is performed» (раздел 3.1). Ключ RS256 знает свой алгоритм, и HMAC с ним не считается вовсе.

kid, jku и x5u

Заголовок умеет подсказать, каким ключом проверять. kid по RFC 7515, раздел 4.1.4 - «a hint indicating which key was used», строка произвольной структуры. Если сервис подставляет её в путь к файлу с ключом или в SQL-запрос, это обычная инъекция: RFC 8725, раздел 3.10 прямо требует, чтобы поиск ключа по kid не создавал SQL- или LDAP-инъекций. С путём к файлу ещё проще: атакующий указывает kid на файл с известным содержимым и подписывает токен этим содержимым.

Пример на маркетплейсе: ключи лежат в /etc/marketplace/keys/<kid>.key, и сервис читает файл по kid из токена. Атакующий пишет kid вида ../../../../dev/null, файл читается пустым, и токен, подписанный HMAC с пустым секретом, проходит. С SQL то же самое: запрос к таблице ключей, склеенный строкой с kid, атакующий заставляет вернуть строку, которую сам же подставил в kid, и подписывает токен ею. Общий класс таких дыр RFC 8725, раздел 2.9 называет indirect attacks: claim, по которому сервер что-то ищет, становится вектором инъекции. Как устроены инъекции вообще, разобрано в статье Инъекции.

jku и x5u ещё опаснее: это URL, откуда взять ключ. Сервис, который идёт по такому адресу, проверяет подпись ключом атакующего, а заодно делает запрос, куда тот скажет: «blindly following a "jku" ... or "x5u" header ... could result in server-side request forgery (SSRF) attacks» (там же).

С jwk всё ещё прямолинейнее: открытый ключ лежит в самом заголовке. Сервис, который проверяет подпись ключом из jwk, проверяет её ключом, который выбрал отправитель, то есть не проверяет ничего.

Что получает атакующий через jku: выигрыш дважды. Первый - он поднимает свой JWKS, подписывает токен своим ключом, и сервис принимает любую роль. Второй - сервис ходит по адресу из токена, значит, атакующий может направить запрос во внутреннюю сеть маркетплейса. Про второе подробно в статье SSRF.

Что говорит RFC 8725: kid проверять и очищать перед поиском, а адреса из jku и x5u сверять со списком разрешённых и не отправлять при этом cookie (раздел 3.10, уровень SHOULD). Проще и надёжнее не ходить по ним вовсе: адрес ключей у сервиса есть в конфигурации.

Что ещё ломается: aud, exp, слабый секрет, токен в адресеспросят на собеседовании

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

Отдельно стоит долгоживущий токен без отзыва: JWT проверяется без похода в хранилище, поэтому украденный токен работает до exp. Чем сессия отличается от JWT и почему access живёт минуты, разобрано в паттернах авторизации, здесь не повторяем.

Токен одного сервиса в другом

Это самая будничная из атак: ничего не подделано. Покупатель обращается в поддержку, и сервис поддержки получает его токен. Если сервис платежей принимает любой токен, подписанный общим сервером авторизации, то сервис поддержки (взломанный, с утёкшими логами или просто с недобросовестным подрядчиком) может отнести этот токен в платежи и действовать от имени покупателя. RFC 8725, раздел 2.7 описывает ровно этот сценарий: ресурс, которому токен предназначен, предъявляет его другому ресурсу, «in an attempt to gain access».

Раздел 3.9 отвечает на это прямо: если издатель выпускает токены для нескольких получателей, в JWT «MUST contain an "aud"», а получатель при отсутствии или несовпадении «MUST reject the JWT».

Разновидность той же беды - токен другого назначения. Раздел 2.8 называет её cross-JWT confusion: ID token, выданный фронтенду, или токен письма для сброса пароля, подписанный тем же ключом, проходит за access token. Раздел 3.12 требует, чтобы правила проверки разных видов JWT от одного издателя были взаимоисключающими, и перечисляет способы: разный typ, разные обязательные claims, разные ключи, разные aud, разные издатели. На практике для access token хватает двух: aud с именем сервиса и, где сервер авторизации это умеет, свой typ (раздел 3.11).

Что получает атакующий: доступ к сервису, к которому его никто не пускал, с настоящей подписью. В логах это выглядит как обычный запрос покупателя.

Чужой издатель

Сервис маркетплейса может доверять нескольким издателям: своему серверу авторизации для покупателей и, например, серверу партнёра для интеграций продавцов. Если сервис собрал ключи обоих в один набор и проверяет только подпись, партнёр подписывает токен своим ключом, пишет в нём iss основного сервера и получает права покупателя. RFC 8725, раздел 3.8 закрывает это: ключ подписи должен принадлежать издателю из iss, иначе приложение «MUST reject the JWT». Тот же раздел приводит способ связать ключи с издателем: в OpenID Connect iss - это https-адрес, в метаданных которого лежит jwks_uri.

На практике это словарь в конфигурации сервиса: издатель -> его jwks_uri -> его ключи. Сначала по iss выбирается запись, и неизвестный iss отклоняется сразу. Потом kid ищется только среди ключей этого издателя. Общего котла ключей, где kid партнёра находит ключ партнёра для токена с чужим iss, нет.

Что получает атакующий: партнёр, а вместе с ним любой, кто украл ключ партнёра, становится любым покупателем маркетплейса. Ущерб от взлома чужой системы растекается на свою.

Нет exp или он не проверяется

Access token утёк: попал в лог прокси, в скриншот в тикете, в историю браузера. С exp через несколько минут он мусор. Без exp, или если сервис срок не смотрит, это вечный пропуск в аккаунт покупателя. RFC 7519 делает exp необязательным claim, поэтому требовать его - работа проверяющего: токен без exp отклоняется так же, как просроченный.

Что говорит RFC: RFC 8725 отдельного правила про срок не вводит, норма живёт в RFC 7519, раздел 4.1.4: токен после exp «MUST NOT be accepted for processing», а допуск на расхождение часов - «usually no more than a few minutes». Что получает атакующий без этой проверки: каждый токен, когда-либо утёкший из логов, работает как действующий.

Слабый секрет

Атакующему хватает одного своего токена: заголовок, claims и подпись у него на руках, и проверять догадки о секрете можно офлайн, перебором или по словарю, сколько угодно быстро, без единого запроса к маркетплейсу (RFC 8725, раздел 2.2). Секрет marketplace2024 из конфига такой перебор переживает недолго. Что получает атакующий: возможность самому выпускать любые токены, как будто он сервер авторизации, пока секрет не сменят. Отдельная беда - один секрет на несколько сервисов. Секрет HMAC и проверяет, и подписывает, поэтому сервис поддержки, которому дали секрет «только для проверки», может выпустить токен для платежей. Асимметричная подпись это снимает: у проверяющих только открытый ключ.

Меры по RFC: раздел 3.5 говорит, что запоминаемые пароли «MUST NOT be directly used as the key», а RFC 7518, раздел 3.2 требует для HS256 ключ не короче 256 бит.

Токен в адресе

Пример с маркетплейса: покупатель скачивает счёт по ссылке /invoices/981.pdf?access_token=.... Адрес с токеном оседает в журнале балансировщика, в истории браузера и уходит в заголовке Referer, если в PDF-просмотрщике есть внешняя ссылка. Кто прочитал журнал, получил полный access token покупателя на весь его срок, и подпись здесь не помогает: токен настоящий. Поэтому RFC 6750, раздел 5.3 пишет, что bearer-токены «SHOULD NOT be passed in page URLs», а шпаргалка OWASP по логированию относит access tokens к тому, что напрямую в лог не пишут; как не уронить токен в лог, разобрано в статье PII и секреты. Для скачивания по ссылке выпускают отдельный короткий токен только на этот файл, а access token ходит в заголовке Authorization.

Раскодировать вместо проверить

Функция чтения без проверки удобна: чтобы положить sub в лог или выбрать шард, не нужен ключ. Беда начинается, когда результат такой функции уходит дальше. Типичный путь: шлюз проверяет токен, а сервис заказов «доверяет шлюзу» и только раскодирует, чтобы узнать покупателя. Любой, кто достучится до сервиса заказов мимо шлюза (из соседнего пода, через SSRF в другом сервисе, через ошибку маршрутизации), присылает токен с чужим sub и без подписи. Это прямой CWE-347: подпись есть, но её никто не проверил. RFC 8725 отдельного раздела под это не выделяет, но раздел 3.3 говорит общее правило: все криптографические операции в JWT «MUST be validated», и при любой неудаче отклоняется весь токен.

Что получает атакующий: любой sub и любую роль без подписи вовсе, то есть заказы, адреса и возвраты любого покупателя. Это хуже, чем alg: none: даже не нужно менять заголовок.

Как выглядит в коде

Типичный самописный проверяльщик: алгоритм берёт из заголовка, ключ один на всё, claims не смотрит. В программе ниже сервису настроен открытый ключ, а атакующий собирает четыре токена: без подписи, с HMAC на открытом ключе, просроченный и выпущенный для другого сервиса.

живой пример

import java.nio.charset.StandardCharsets;
import java.util.Base64;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;

public class NaiveJwt {
    static final String PUBLIC_KEY = "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAu1SU1LfVLPHCozMxH2Mo\n-----END PUBLIC KEY-----\n";
    static final Pattern FIELD = Pattern.compile("\"(\\w+)\":(\"[^\"]*\"|-?\\d+)");

    static String b64(byte[] data) {
        return Base64.getUrlEncoder().withoutPadding().encodeToString(data);
    }

    static String json(String part) {
        return new String(Base64.getUrlDecoder().decode(part), StandardCharsets.UTF_8);
    }

    static Map<String, String> fields(String json) {
        Map<String, String> out = new LinkedHashMap<>();
        Matcher m = FIELD.matcher(json);
        while (m.find()) out.put(m.group(1), m.group(2).replace("\"", ""));
        return out;
    }

    static String hs256(String secret, String input) throws Exception {
        Mac mac = Mac.getInstance("HmacSHA256");
        mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
        return b64(mac.doFinal(input.getBytes(StandardCharsets.UTF_8)));
    }

    static String sign(String header, String claims, String secret) throws Exception {
        String input = b64(header.getBytes(StandardCharsets.UTF_8)) + "." + b64(claims.getBytes(StandardCharsets.UTF_8));
        return secret == null ? input + "." : input + "." + hs256(secret, input);
    }

    static Map<String, String> naiveVerify(String token, String key) throws Exception {
        String[] p = token.split("\\.", -1);
        String alg = fields(json(p[0])).get("alg");
        Map<String, String> claims = fields(json(p[1]));
        if (alg.equals("none")) return claims;
        if (alg.equals("HS256") && hs256(key, p[0] + "." + p[1]).equals(p[2])) return claims;
        throw new SecurityException("подпись не совпала");
    }

    public static void main(String[] args) throws Exception {
        long now = System.currentTimeMillis() / 1000;
        String admin = "{\"sub\":\"buyer-17\",\"role\":\"admin\",\"aud\":\"%s\",\"exp\":%d}";
        String hs = "{\"alg\":\"HS256\"}";
        Map<String, String> attacks = new LinkedHashMap<>();
        attacks.put("alg=none", sign("{\"alg\":\"none\"}", admin.formatted("orders-service", now + 600), null));
        attacks.put("HS256 на публичном ключе", sign(hs, admin.formatted("orders-service", now + 600), PUBLIC_KEY));
        attacks.put("истёк час назад", sign(hs, admin.formatted("orders-service", now - 3600), PUBLIC_KEY));
        attacks.put("выпущен для support-service", sign(hs, admin.formatted("support-service", now + 600), PUBLIC_KEY));
        for (var attack : attacks.entrySet()) {
            Map<String, String> claims = naiveVerify(attack.getValue(), PUBLIC_KEY);
            System.out.println("атака прошла: " + attack.getKey() + " -> sub=" + claims.get("sub") + " role=" + claims.get("role"));
        }
    }
}
Запустить

Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →

живой пример

package main

import (
	"crypto/hmac"
	"crypto/sha256"
	"encoding/base64"
	"encoding/json"
	"errors"
	"fmt"
	"strings"
	"time"
)

var publicKey = []byte("-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAu1SU1LfVLPHCozMxH2Mo\n-----END PUBLIC KEY-----\n")

var b64 = base64.RawURLEncoding

func hs256(secret []byte, input string) string {
	mac := hmac.New(sha256.New, secret)
	mac.Write([]byte(input))
	return b64.EncodeToString(mac.Sum(nil))
}

func sign(header, claims map[string]any, secret []byte) string {
	h, _ := json.Marshal(header)
	c, _ := json.Marshal(claims)
	input := b64.EncodeToString(h) + "." + b64.EncodeToString(c)
	if secret == nil {
		return input + "."
	}
	return input + "." + hs256(secret, input)
}

func naiveVerify(token string, key []byte) (map[string]any, error) {
	parts := strings.Split(token, ".")
	var header, claims map[string]any
	h, _ := b64.DecodeString(parts[0])
	p, _ := b64.DecodeString(parts[1])
	json.Unmarshal(h, &header)
	json.Unmarshal(p, &claims)
	switch header["alg"] {
	case "none":
		return claims, nil
	case "HS256":
		if hs256(key, parts[0]+"."+parts[1]) == parts[2] {
			return claims, nil
		}
	}
	return nil, errors.New("подпись не совпала")
}

func with(base map[string]any, key string, value any) map[string]any {
	out := map[string]any{key: value}
	for k, v := range base {
		if k != key {
			out[k] = v
		}
	}
	return out
}

func main() {
	now := time.Now().Unix()
	admin := map[string]any{"sub": "buyer-17", "role": "admin", "aud": "orders-service", "exp": now + 600}
	hs := map[string]any{"alg": "HS256"}
	attacks := []struct{ name, token string }{
		{"alg=none", sign(map[string]any{"alg": "none"}, admin, nil)},
		{"HS256 на публичном ключе", sign(hs, admin, publicKey)},
		{"истёк час назад", sign(hs, with(admin, "exp", now-3600), publicKey)},
		{"выпущен для support-service", sign(hs, with(admin, "aud", "support-service"), publicKey)},
	}
	for _, a := range attacks {
		claims, err := naiveVerify(a.token, publicKey)
		if err != nil {
			fmt.Println("отклонён:", a.name, err)
			continue
		}
		fmt.Printf("атака прошла: %s -> sub=%v role=%v\n", a.name, claims["sub"], claims["role"])
	}
}
Запустить

Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →

живой пример

const crypto = require('crypto');

const PUBLIC_KEY = '-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAu1SU1LfVLPHCozMxH2Mo\n-----END PUBLIC KEY-----\n';

const b64 = (value) => Buffer.from(value).toString('base64url');
const hs256 = (secret, input) => crypto.createHmac('sha256', secret).update(input).digest('base64url');

function sign(header, claims, secret) {
  const input = `${b64(JSON.stringify(header))}.${b64(JSON.stringify(claims))}`;
  return secret === null ? `${input}.` : `${input}.${hs256(secret, input)}`;
}

function naiveVerify(token, key) {
  const [h, p, s] = token.split('.');
  const { alg } = JSON.parse(Buffer.from(h, 'base64url'));
  if (alg === 'none') return JSON.parse(Buffer.from(p, 'base64url'));
  if (alg === 'HS256' && hs256(key, `${h}.${p}`) === s) return JSON.parse(Buffer.from(p, 'base64url'));
  throw new Error('подпись не совпала');
}

const now = Math.floor(Date.now() / 1000);
const admin = { sub: 'buyer-17', role: 'admin', aud: 'orders-service', exp: now + 600 };
const attacks = {
  'alg=none': sign({ alg: 'none' }, admin, null),
  'HS256 на публичном ключе': sign({ alg: 'HS256' }, admin, PUBLIC_KEY),
  'истёк час назад': sign({ alg: 'HS256' }, { ...admin, exp: now - 3600 }, PUBLIC_KEY),
  'выпущен для support-service': sign({ alg: 'HS256' }, { ...admin, aud: 'support-service' }, PUBLIC_KEY),
};
for (const [name, token] of Object.entries(attacks)) {
  const claims = naiveVerify(token, PUBLIC_KEY);
  console.log(`атака прошла: ${name} -> sub=${claims.sub} role=${claims.role}`);
}
Запустить

Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →

живой пример

import base64
import hashlib
import hmac
import json
import time

PUBLIC_KEY = b"-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAu1SU1LfVLPHCozMxH2Mo\n-----END PUBLIC KEY-----\n"


def b64(data: bytes) -> str:
    return base64.urlsafe_b64encode(data).rstrip(b"=").decode()


def unb64(part: str) -> bytes:
    return base64.urlsafe_b64decode(part + "=" * (-len(part) % 4))


def sign(header: dict, claims: dict, secret: bytes | None) -> str:
    signing_input = b64(json.dumps(header).encode()) + "." + b64(json.dumps(claims).encode())
    if secret is None:
        return signing_input + "."
    mac = hmac.new(secret, signing_input.encode(), hashlib.sha256).digest()
    return signing_input + "." + b64(mac)


def naive_verify(token: str, key: bytes) -> dict:
    h, p, s = token.split(".")
    alg = json.loads(unb64(h))["alg"]
    if alg == "none":
        return json.loads(unb64(p))
    if alg == "HS256":
        expected = b64(hmac.new(key, f"{h}.{p}".encode(), hashlib.sha256).digest())
        if expected == s:
            return json.loads(unb64(p))
    raise ValueError("подпись не совпала")


now = int(time.time())
admin = {"sub": "buyer-17", "role": "admin", "aud": "orders-service", "exp": now + 600}
attacks = {
    "alg=none": sign({"alg": "none"}, admin, None),
    "HS256 на публичном ключе": sign({"alg": "HS256"}, admin, PUBLIC_KEY),
    "истёк час назад": sign({"alg": "HS256"}, {**admin, "exp": now - 3600}, PUBLIC_KEY),
    "выпущен для support-service": sign({"alg": "HS256"}, {**admin, "aud": "support-service"}, PUBLIC_KEY),
}
for name, token in attacks.items():
    claims = naive_verify(token, PUBLIC_KEY)
    print(f"атака прошла: {name} -> sub={claims['sub']} role={claims['role']}")
Запустить

Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →

Разберём, что произошло в каждой строке вывода.

  • alg=none: ветка none возвращает claims, не глядя на третью часть токена. Покупатель стал администратором.
  • HS256 на публичном ключе: проверяльщику настроен один ключ, и он отдаёт его в HMAC, раз так просит заголовок. Текст открытого ключа знают все, подпись сходится.
  • истёк час назад: подпись настоящая, но exp никто не читает. Токен из старого лога работает вечно.
  • выпущен для support-service: подпись настоящая, aud никто не читает. Токен поддержки открывает заказы.

Все четыре атаки проходят, и у всех одна причина: решение о том, как проверять, принимает содержимое токена. Сравнение подписи через обычное равенство строк добавляет пятую проблему: оно останавливается на первом несовпавшем символе, и время ответа подсказывает, сколько символов угадано (CWE-208).

Как чинить: правила RFC 8725спросят на собеседовании

Каждая мера закрывает одну атаку из списка выше.

  1. Алгоритм задаёт проверяющий, а не токен. RFC 8725, раздел 3.1: библиотека «MUST enable the caller to specify a supported set of algorithms and MUST NOT use any other algorithms», и «each key MUST be used with exactly one algorithm». Ключ RS256 никогда не служит секретом HMAC, и none не входит в список. Раздел 3.2 добавляет, что библиотеки не должны принимать none, если вызывающий явно не попросил.
  2. Ключ выбирается из своего списка. kid только ищут в заранее загруженном наборе ключей своего издателя: нет такого kid - токен отклонён. В путь, SQL и URL он не попадает, и вопрос об очистке kid из раздела 3.10 снимается сам: поиск в словаре не инъецируется.
  3. jku и x5u из токена игнорируются. Адрес JWKS берётся из конфигурации сервиса, обычно из метаданных издателя (jwks_uri), как описывает раздел 3.8. Если без jku никак, раздел 3.10 советует (SHOULD) сверять URL со списком разрешённых.
  4. Проверяются все claims. exp обязателен, iss равен своему издателю (раздел 3.8), aud содержит имя сервиса (раздел 3.9). Тип токена можно закрепить через typ (раздел 3.11), чтобы ID token или токен другого назначения не прошёл за access token.
  5. Расхождение часов - секунды, не часы. RFC 7519 разрешает «some small leeway, usually no more than a few minutes, to account for clock skew». Допуск в сутки превращает истёкший токен в действующий.
  6. Секрет HS256 - случайные 32 байта и больше, из генератора криптостойких случайных чисел и хранилища секретов, не слово из конфига (раздел 3.5). Если токен проверяют несколько сервисов, лучше асимметричная подпись: у сервисов будет только открытый ключ.
  7. Подпись сравнивается за постоянное время: MessageDigest.isEqual, hmac.Equal, crypto.timingSafeEqual, hmac.compare_digest.

Обратите внимание на форму правил: это списки разрешённого, а не запреты. Проверка «alg не равен none» закрывает одну атаку из двух и пропускает подмену на HS256. Проверка «alg равен RS256» закрывает обе и любую следующую, о которой ещё никто не написал. RFC 8725, раздел 3.1 сформулирован именно так: вызывающий задаёт «a supported set of algorithms», всё остальное не используется.

То же с ключами: не «отбросить подозрительный kid», а «найти kid в своём наборе», не «запретить jku на внутренние адреса», а «не ходить по jku». Запрет приходится дописывать после каждой новой атаки, разрешённый список ничего не надо дописывать.

Программа ниже собирает эти проверки в один минимальный проверяльщик HS256 на стандартной библиотеке. Она нужна, чтобы увидеть каждую проверку по отдельности. В бою JWT разбирает библиотека с закреплённым алгоритмом (о ней ниже), а не самописный код: в разборе токена слишком много мест, где легко ошибиться.

живой пример

import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.SecureRandom;
import java.util.Arrays;
import java.util.Base64;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;

public class StrictJwt {
    static final String ALG = "HS256";
    static final String ISSUER = "https://auth.marketplace.example";
    static final String AUDIENCE = "orders-service";
    static final long LEEWAY_SECONDS = 30;
    static final Map<String, byte[]> KEYS = Map.of("orders-2026-10", randomKey());
    static final String PUBLIC_KEY = "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAu1SU1LfVLPHCozMxH2Mo\n-----END PUBLIC KEY-----\n";
    static final Pattern MEMBER = Pattern.compile("\"(\\w+)\":(?:\"([^\"\\\\]*)\"|(-?\\d+)|\\[([^\\]]*)\\])([,}])");

    static byte[] randomKey() {
        byte[] key = new byte[32];
        new SecureRandom().nextBytes(key);
        return key;
    }

    static String b64(byte[] data) {
        return Base64.getUrlEncoder().withoutPadding().encodeToString(data);
    }

    static byte[] hs256(byte[] secret, String input) throws Exception {
        Mac mac = Mac.getInstance("HmacSHA256");
        mac.init(new SecretKeySpec(secret, "HmacSHA256"));
        return mac.doFinal(input.getBytes(StandardCharsets.UTF_8));
    }

    static String sign(String header, String claims, byte[] secret) throws Exception {
        String input = b64(header.getBytes(StandardCharsets.UTF_8)) + "." + b64(claims.getBytes(StandardCharsets.UTF_8));
        return secret == null ? input + "." : input + "." + b64(hs256(secret, input));
    }

    static Map<String, Object> parseFlatJson(String part) {
        String json = new String(Base64.getUrlDecoder().decode(part), StandardCharsets.UTF_8);
        Map<String, Object> out = new HashMap<>();
        Matcher m = MEMBER.matcher(json);
        int pos = 1;
        while (json.startsWith("{") && m.region(pos, json.length()).lookingAt()) {
            Object value = m.group(2) != null ? m.group(2)
                    : m.group(3) != null ? (Object) Long.parseLong(m.group(3))
                    : Arrays.stream(m.group(4).split(",")).map(s -> s.replace("\"", "")).toList();
            if (out.put(m.group(1), value) != null) throw new SecurityException("ключ дважды: " + m.group(1));
            pos = m.end();
            if (m.group(5).equals("}") && pos == json.length()) return out;
        }
        throw new SecurityException("неожиданный JSON");
    }

    static Map<String, Object> verify(String token, long now) throws Exception {
        String[] parts = token.split("\\.", -1);
        if (parts.length != 3) throw new SecurityException("не три части");
        Map<String, Object> header = parseFlatJson(parts[0]);
        if (!ALG.equals(header.get("alg"))) throw new SecurityException("alg " + header.get("alg") + ", ждём " + ALG);
        byte[] key = header.get("kid") instanceof String kid ? KEYS.get(kid) : null;
        if (key == null) throw new SecurityException("неизвестный kid");
        byte[] expected = hs256(key, parts[0] + "." + parts[1]);
        if (!MessageDigest.isEqual(expected, Base64.getUrlDecoder().decode(parts[2]))) {
            throw new SecurityException("подпись не совпала");
        }
        Map<String, Object> claims = parseFlatJson(parts[1]);
        if (!(claims.get("exp") instanceof Long exp) || now >= exp + LEEWAY_SECONDS) {
            throw new SecurityException("нет exp или токен истёк");
        }
        if (!ISSUER.equals(claims.get("iss"))) throw new SecurityException("чужой iss");
        Object aud = claims.get("aud");
        if (!(AUDIENCE.equals(aud) || aud instanceof List<?> list && list.contains(AUDIENCE))) {
            throw new SecurityException("aud не " + AUDIENCE);
        }
        return claims;
    }

    public static void main(String[] args) throws Exception {
        long now = System.currentTimeMillis() / 1000;
        String header = "{\"alg\":\"%s\",\"typ\":\"JWT\",\"kid\":\"%s\"}";
        String claims = "{\"iss\":\"" + ISSUER + "\",\"sub\":\"buyer-17\",\"aud\":\"%s\",\"exp\":%d}";
        String own = header.formatted("HS256", "orders-2026-10");
        byte[] ownKey = KEYS.get("orders-2026-10");
        String good = claims.formatted(AUDIENCE, now + 300);
        Object[][] cases = {
            {"свой токен", sign(own, good, ownKey), true},
            {"alg=none", sign(header.formatted("none", "orders-2026-10"), good, null), false},
            {"HS256 на публичном ключе", sign(own, good, PUBLIC_KEY.getBytes(StandardCharsets.UTF_8)), false},
            {"истёкший exp", sign(own, claims.formatted(AUDIENCE, now - 3600), ownKey), false},
            {"чужой aud", sign(own, claims.formatted("support-service", now + 300), ownKey), false},
            {"kid с путём", sign(header.formatted("HS256", "../../dev/null"), good, new byte[1]), false},
        };
        for (Object[] c : cases) {
            String result;
            boolean passed;
            try {
                verify((String) c[1], now);
                result = "принят";
                passed = true;
            } catch (SecurityException e) {
                result = "отклонён (" + e.getMessage() + ")";
                passed = false;
            }
            System.out.println((passed == (boolean) c[2] ? "OK   " : "FAIL ") + c[0] + ": " + result);
        }
    }
}
Запустить

Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →

живой пример

package main

import (
	"crypto/hmac"
	"crypto/rand"
	"crypto/sha256"
	"encoding/base64"
	"encoding/json"
	"errors"
	"fmt"
	"slices"
	"strings"
	"time"
)

const (
	alg           = "HS256"
	issuer        = "https://auth.marketplace.example"
	audience      = "orders-service"
	leewaySeconds = 30
)

var (
	keys      = map[string][]byte{"orders-2026-10": randomKey()}
	publicKey = []byte("-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAu1SU1LfVLPHCozMxH2Mo\n-----END PUBLIC KEY-----\n")
	b64       = base64.RawURLEncoding
)

type header struct {
	Alg string `json:"alg"`
	Typ string `json:"typ,omitempty"`
	Kid string `json:"kid,omitempty"`
}

type claims struct {
	Iss string          `json:"iss"`
	Sub string          `json:"sub"`
	Aud json.RawMessage `json:"aud"`
	Exp *int64          `json:"exp"`
}

func randomKey() []byte {
	key := make([]byte, 32)
	rand.Read(key)
	return key
}

func hs256(secret []byte, input string) []byte {
	mac := hmac.New(sha256.New, secret)
	mac.Write([]byte(input))
	return mac.Sum(nil)
}

func sign(h header, c map[string]any, secret []byte) string {
	hj, _ := json.Marshal(h)
	cj, _ := json.Marshal(c)
	input := b64.EncodeToString(hj) + "." + b64.EncodeToString(cj)
	if secret == nil {
		return input + "."
	}
	return input + "." + b64.EncodeToString(hs256(secret, input))
}

func audiences(raw json.RawMessage) []string {
	var one string
	if json.Unmarshal(raw, &one) == nil {
		return []string{one}
	}
	var many []string
	json.Unmarshal(raw, &many)
	return many
}

func verify(token string, now int64) (*claims, error) {
	parts := strings.Split(token, ".")
	if len(parts) != 3 {
		return nil, errors.New("не три части")
	}
	var h header
	if raw, err := b64.DecodeString(parts[0]); err != nil || json.Unmarshal(raw, &h) != nil {
		return nil, errors.New("битый заголовок")
	}
	if h.Alg != alg {
		return nil, fmt.Errorf("alg %s, ждём %s", h.Alg, alg)
	}
	key, ok := keys[h.Kid]
	if !ok {
		return nil, errors.New("неизвестный kid")
	}
	actual, err := b64.DecodeString(parts[2])
	if err != nil || !hmac.Equal(actual, hs256(key, parts[0]+"."+parts[1])) {
		return nil, errors.New("подпись не совпала")
	}
	var c claims
	if raw, err := b64.DecodeString(parts[1]); err != nil || json.Unmarshal(raw, &c) != nil {
		return nil, errors.New("битые claims")
	}
	if c.Exp == nil || now >= *c.Exp+leewaySeconds {
		return nil, errors.New("нет exp или токен истёк")
	}
	if c.Iss != issuer {
		return nil, errors.New("чужой iss")
	}
	if !slices.Contains(audiences(c.Aud), audience) {
		return nil, errors.New("aud не " + audience)
	}
	return &c, nil
}

func with(base map[string]any, key string, value any) map[string]any {
	out := map[string]any{key: value}
	for k, v := range base {
		if k != key {
			out[k] = v
		}
	}
	return out
}

func main() {
	now := time.Now().Unix()
	h := header{Alg: "HS256", Typ: "JWT", Kid: "orders-2026-10"}
	c := map[string]any{"iss": issuer, "sub": "buyer-17", "aud": audience, "exp": now + 300}
	ownKey := keys["orders-2026-10"]
	cases := []struct {
		name, token string
		shouldPass  bool
	}{
		{"свой токен", sign(h, c, ownKey), true},
		{"alg=none", sign(header{Alg: "none", Typ: "JWT", Kid: h.Kid}, c, nil), false},
		{"HS256 на публичном ключе", sign(h, with(c, "role", "admin"), publicKey), false},
		{"истёкший exp", sign(h, with(c, "exp", now-3600), ownKey), false},
		{"чужой aud", sign(h, with(c, "aud", "support-service"), ownKey), false},
		{"kid с путём", sign(header{Alg: "HS256", Kid: "../../dev/null"}, c, []byte{}), false},
	}
	for _, tc := range cases {
		result, passed := "принят", true
		if _, err := verify(tc.token, now); err != nil {
			result, passed = "отклонён ("+err.Error()+")", false
		}
		status := "FAIL"
		if passed == tc.shouldPass {
			status = "OK  "
		}
		fmt.Printf("%s %s: %s\n", status, tc.name, result)
	}
}
Запустить

Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →

живой пример

const crypto = require('crypto');

const ALG = 'HS256';
const ISSUER = 'https://auth.marketplace.example';
const AUDIENCE = 'orders-service';
const LEEWAY_SECONDS = 30;
const KEYS = new Map([['orders-2026-10', crypto.randomBytes(32)]]);
const PUBLIC_KEY = '-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAu1SU1LfVLPHCozMxH2Mo\n-----END PUBLIC KEY-----\n';

const b64 = (value) => Buffer.from(value).toString('base64url');
const unb64 = (part) => Buffer.from(part, 'base64url');
const hs256 = (secret, input) => crypto.createHmac('sha256', secret).update(input).digest();

function sign(header, claims, secret) {
  const input = `${b64(JSON.stringify(header))}.${b64(JSON.stringify(claims))}`;
  return secret === null ? `${input}.` : `${input}.${b64(hs256(secret, input))}`;
}

function verify(token, now) {
  const parts = token.split('.');
  if (parts.length !== 3) throw new Error('не три части');
  const header = JSON.parse(unb64(parts[0]));
  if (header.alg !== ALG) throw new Error(`alg ${header.alg}, ждём ${ALG}`);
  const key = KEYS.get(header.kid);
  if (!key) throw new Error('неизвестный kid');
  const expected = hs256(key, `${parts[0]}.${parts[1]}`);
  const actual = unb64(parts[2]);
  if (actual.length !== expected.length || !crypto.timingSafeEqual(actual, expected)) {
    throw new Error('подпись не совпала');
  }
  const claims = JSON.parse(unb64(parts[1]));
  if (!Number.isInteger(claims.exp) || now >= claims.exp + LEEWAY_SECONDS) throw new Error('нет exp или токен истёк');
  if (claims.iss !== ISSUER) throw new Error('чужой iss');
  if (![].concat(claims.aud).includes(AUDIENCE)) throw new Error(`aud не ${AUDIENCE}`);
  return claims;
}

const now = Math.floor(Date.now() / 1000);
const header = { alg: 'HS256', typ: 'JWT', kid: 'orders-2026-10' };
const claims = { iss: ISSUER, sub: 'buyer-17', aud: AUDIENCE, exp: now + 300 };
const ownKey = KEYS.get('orders-2026-10');
const cases = [
  ['свой токен', sign(header, claims, ownKey), true],
  ['alg=none', sign({ ...header, alg: 'none' }, claims, null), false],
  ['HS256 на публичном ключе', sign(header, { ...claims, role: 'admin' }, PUBLIC_KEY), false],
  ['истёкший exp', sign(header, { ...claims, exp: now - 3600 }, ownKey), false],
  ['чужой aud', sign(header, { ...claims, aud: 'support-service' }, ownKey), false],
  ['kid с путём', sign({ ...header, kid: '../../dev/null' }, claims, Buffer.alloc(0)), false],
];
for (const [name, token, shouldPass] of cases) {
  let result;
  let passed;
  try {
    verify(token, now);
    [result, passed] = ['принят', true];
  } catch (e) {
    [result, passed] = [`отклонён (${e.message})`, false];
  }
  console.log(`${passed === shouldPass ? 'OK  ' : 'FAIL'} ${name}: ${result}`);
}
Запустить

Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →

живой пример

import base64
import hashlib
import hmac
import json
import os
import time

ALG = "HS256"
ISSUER = "https://auth.marketplace.example"
AUDIENCE = "orders-service"
LEEWAY_SECONDS = 30
KEYS = {"orders-2026-10": os.urandom(32)}
PUBLIC_KEY = b"-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAu1SU1LfVLPHCozMxH2Mo\n-----END PUBLIC KEY-----\n"


def b64(data: bytes) -> str:
    return base64.urlsafe_b64encode(data).rstrip(b"=").decode()


def unb64(part: str) -> bytes:
    return base64.urlsafe_b64decode(part + "=" * (-len(part) % 4))


def sign(header: dict, claims: dict, secret: bytes | None) -> str:
    signing_input = b64(json.dumps(header).encode()) + "." + b64(json.dumps(claims).encode())
    if secret is None:
        return signing_input + "."
    return signing_input + "." + b64(hmac.new(secret, signing_input.encode(), hashlib.sha256).digest())


def verify(token: str, now: int) -> dict:
    parts = token.split(".")
    if len(parts) != 3:
        raise ValueError("не три части")
    header = json.loads(unb64(parts[0]))
    if header.get("alg") != ALG:
        raise ValueError(f"alg {header.get('alg')}, ждём {ALG}")
    key = KEYS.get(header.get("kid"))
    if key is None:
        raise ValueError("неизвестный kid")
    expected = hmac.new(key, f"{parts[0]}.{parts[1]}".encode(), hashlib.sha256).digest()
    if not hmac.compare_digest(expected, unb64(parts[2])):
        raise ValueError("подпись не совпала")
    claims = json.loads(unb64(parts[1]))
    if not isinstance(claims.get("exp"), int) or now >= claims["exp"] + LEEWAY_SECONDS:
        raise ValueError("нет exp или токен истёк")
    if claims.get("iss") != ISSUER:
        raise ValueError("чужой iss")
    aud = claims.get("aud")
    if AUDIENCE not in (aud if isinstance(aud, list) else [aud]):
        raise ValueError(f"aud не {AUDIENCE}")
    return claims


now = int(time.time())
header = {"alg": "HS256", "typ": "JWT", "kid": "orders-2026-10"}
claims = {"iss": ISSUER, "sub": "buyer-17", "aud": AUDIENCE, "exp": now + 300}
own_key = KEYS["orders-2026-10"]
cases = [
    ("свой токен", sign(header, claims, own_key), True),
    ("alg=none", sign({**header, "alg": "none"}, claims, None), False),
    ("HS256 на публичном ключе", sign(header, {**claims, "role": "admin"}, PUBLIC_KEY), False),
    ("истёкший exp", sign(header, {**claims, "exp": now - 3600}, own_key), False),
    ("чужой aud", sign(header, {**claims, "aud": "support-service"}, own_key), False),
    ("kid с путём", sign({**header, "kid": "../../dev/null"}, claims, b""), False),
]
for name, token, should_pass in cases:
    try:
        verify(token, now)
        result, passed = "принят", True
    except ValueError as e:
        result, passed = f"отклонён ({e})", False
    print(f"{'OK  ' if passed == should_pass else 'FAIL'} {name}: {result}")
Запустить

Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →

Порядок проверок важен. Сначала алгоритм, потом ключ по kid из своего списка, потом подпись, и только после подписи claims: до проверки подписи содержимому токена верить нельзя. alg: none отсекается первой же строкой, HMAC на открытом ключе не сходится с настоящим секретом, а kid с путём не находится в списке ключей.

Как строки вывода соотносятся с правилами из списка выше:

  • alg=none - правило 1: алгоритм сверяется с константой ALG, а не выбирается по заголовку.
  • HS256 на публичном ключе - правила 1 и 2: ключ берётся из KEYS по kid, и текст открытого ключа там не лежит, поэтому подпись не сходится.
  • истёкший exp - правила 4 и 5: exp обязателен, допуск LEEWAY_SECONDS равен 30 секундам.
  • чужой aud - правило 4: aud должен содержать orders-service, строкой или в списке.
  • kid с путём - правило 2: kid только ищут в словаре, файловой системы проверяльщик не касается.
  • свой токен - контрольная строка: без неё набор отказов мог бы проходить и у проверяльщика, который отклоняет вообще всё.

Java-версия заодно отклоняет JSON с повторённым ключом. RFC 7519, раздел 4 требует, чтобы имена claims были уникальны, а парсер либо отклонял дубли, либо брал последний. Самописный разбор, который берёт первый, расходится с библиотекой, которая берёт последний, и два sub в одном токене дают два разных пользователя.

В бою: библиотека с закреплённым алгоритмом

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

  • список алгоритмов задан явно и состоит из того, чем реально подписывает сервер авторизации;
  • издатель задан явно и совпадает с iss в токенах;
  • аудитория задана явно и равна имени этого сервиса;
  • exp обязателен, а не проверяется «если есть»;
  • допуск по часам - секунды;
  • ключи берутся по адресу JWKS из конфигурации, а не из токена.

В Spring Security resource server NimbusJwtDecoder по умолчанию доверяет только RS256, и это можно закрепить явно через jwsAlgorithm или свойство spring.security.oauth2.resourceserver.jwt.jws-algorithms. JwtValidators.createDefaultWithIssuer проверяет iss и сроки с допуском по часам 60 секунд по умолчанию; aud добавляют отдельным валидатором (в Spring Boot для этого есть свойство audiences).

@Bean
JwtDecoder jwtDecoder(@Value("${auth.issuer}") String issuer) {
    NimbusJwtDecoder decoder = NimbusJwtDecoder.withIssuerLocation(issuer)
            .jwsAlgorithm(SignatureAlgorithm.RS256)
            .build();
    OAuth2TokenValidator<Jwt> audience = new JwtClaimValidator<List<String>>(
            JwtClaimNames.AUD, aud -> aud != null && aud.contains("orders-service"));
    decoder.setJwtValidator(new DelegatingOAuth2TokenValidator<>(
            JwtValidators.createDefaultWithIssuer(issuer), audience));
    return decoder;
}

В golang-jwt список алгоритмов задаёт WithValidMethods, и авторы библиотеки в комментарии к нему пишут, что его использовать «heavily encouraged». none библиотека принимает, только если ключом передан особый jwt.UnsafeAllowNoneSignatureType. keys.PublicKey здесь - свой кеш JWKS, который ищет kid только среди загруженных ключей. В lestrrat-go/jwx ключ передают сразу с алгоритмом: jwt.Parse(raw, jwt.WithKey(jwa.RS256(), pubkey)).

parser := jwt.NewParser(
	jwt.WithValidMethods([]string{"RS256"}),
	jwt.WithIssuer("https://auth.marketplace.example"),
	jwt.WithAudience("orders-service"),
	jwt.WithExpirationRequired(),
	jwt.WithLeeway(30*time.Second),
)
token, err := parser.Parse(raw, func(t *jwt.Token) (any, error) {
	kid, _ := t.Header["kid"].(string)
	return keys.PublicKey(kid)
})
if err != nil || !token.Valid {
	http.Error(w, "unauthorized", http.StatusUnauthorized)
	return
}

В jose jwtVerify без algorithms принимает все алгоритмы, подходящие к ключу, а alg: none не принимает никогда; audience и issuer требуют наличия claim. В jsonwebtoken то же самое делает jwt.verify(token, key, { algorithms: ['RS256'], audience, issuer }); без algorithms список выбирается по типу ключа, а jwt.decode, по документации, «will not verify whether the signature is valid».

import { createRemoteJWKSet, jwtVerify } from 'jose';

const jwks = createRemoteJWKSet(new URL('https://auth.marketplace.example/.well-known/jwks.json'));

export async function verifyAccessToken(token) {
  const { payload } = await jwtVerify(token, jwks, {
    algorithms: ['RS256'],
    issuer: 'https://auth.marketplace.example',
    audience: 'orders-service',
    requiredClaims: ['exp'],
    clockTolerance: 30,
  });
  return payload;
}

В PyJWT jwt.decode без algorithms падает с ошибкой «It is required that you pass in a value for the "algorithms" argument», это защита от подмены алгоритма. Опасен обратный ход: options={"verify_signature": False} выключает проверку, и документация называет это «generally ill-advised». PyJWKClient сам перечитывает JWKS, если kid не нашёлся.

import jwt

jwks = jwt.PyJWKClient("https://auth.marketplace.example/.well-known/jwks.json")


def verify_access_token(token: str) -> dict:
    signing_key = jwks.get_signing_key_from_jwt(token)
    return jwt.decode(
        token,
        signing_key.key,
        algorithms=["RS256"],
        audience="orders-service",
        issuer="https://auth.marketplace.example",
        leeway=30,
        options={"require": ["exp", "iss", "aud"]},
    )

Как связать это с Keycloak и почему без проверки aud проходит любой токен realm, разобрано в статье Keycloak и проверка токенов; по языкам есть отдельные разборы проверки JWT для Go, Node и Python.

Ротация ключей через JWKS и kidспросят на собеседовании

Ключ подписи меняют по плану (раз в несколько месяцев) и срочно (утёк). В обоих случаях ротация не должна выкидывать всех покупателей маркетплейса на экран входа. Для этого и нужен kid: по RFC 7517, раздел 4.5 он служит, в частности, «to choose among a set of keys within a JWK Set during key rollover», а разные ключи в одном наборе «SHOULD use distinct "kid" values».

Схему описывает OpenID Connect Core, раздел 10.1.1. Издатель публикует ключи в JWK Set по jwks_uri и пишет kid ключа подписи в заголовок каждого токена. Новые ключи периодически добавляются в набор, издатель переходит на новый ключ, когда сочтёт нужным, а проверяющий, увидев незнакомый kid, заново скачивает набор. Выведенные из работы ключи набор «SHOULD retain ... for a reasonable period of time».

По шагам для сервера авторизации маркетплейса:

  1. Сгенерировать новую пару и опубликовать открытый ключ в JWKS рядом со старым, с новым kid (например, 2026-11). Подписывать пока старым.
  2. Подождать, пока проверяющие обновят кеш JWKS. Незнакомый kid и так вызовет перечитывание, но запас по времени страхует сервисы, у которых перечитывание ограничено паузой.
  3. Начать подписывать новым ключом.
  4. Держать старый открытый ключ в JWKS, пока не истечёт последний подписанный им токен: самый долгий срок access token плюс допуск по часам.
  5. Убрать старый ключ из набора.

Что ломается, если шаги перепутать:

  • подписали новым ключом раньше, чем опубликовали его: сервисы с паузой перечитывания отвечают 401 на новые токены, пока пауза не пройдёт;
  • удалили старый ключ сразу после переключения: живые токены, подписанные им, получают 401 до своего exp;
  • проверяющий держит JWKS в кеше до перезапуска и не перечитывает его при незнакомом kid: после переключения подписи он отклоняет всё, пока его не перезапустят.

При утечке ключа шаг 4 пропускают: старый ключ убирают сразу, и все токены, подписанные им, перестают проходить. Пользователям придётся получить новые токены, и тут короткий срок access token окупается: теряются минуты чужих сессий, а не дни.

Со стороны проверяющего сервиса всё сводится к трём правилам, подробнее они разобраны в разделе «Глубже: kid, JWKS и jku» ниже. Ключи берутся только по jwks_uri своего издателя и лежат в памяти по kid. Незнакомый kid вызывает одно перечитывание с паузой, а если ключа нет и после него, токен отклоняется. Алгоритм берётся из конфигурации сервиса или из поля alg самого ключа в наборе (RFC 7517, раздел 4.4), но не из заголовка токена.

Для HMAC схема та же, только набор ключей не публикуется, а приходит из хранилища секретов: в живом примере выше словарь KEYS с ключом orders-2026-10 и есть такой набор. Новый секрет добавляют под новым kid, старый удаляют после срока жизни токенов.

Как проверить

Тесты на отказ. Набор из живого примера переносится в интеграционный тест сервиса один в один: токен без подписи, HMAC на открытом ключе, чужой aud, чужой iss, истёкший exp, токен без exp, kid, которого нет в JWKS. Каждый должен получить 401. Тест «свой токен принят» без них ничего не доказывает.

Unit-тесты проверяльщика. Таблица cases из исправленного примера - готовый каркас: строка «название, токен, должен ли пройти». В тест сервиса её переносят с ключом из тестового набора и добавляют строки, которых в примере нет: токен без exp, чужой iss, aud списком без своего сервиса, токен из двух частей. Ключи генерируются в самом тесте, а время подаётся параметром, как now в примере, чтобы проверка срока не зависела от часов машины CI.

Тест на none собирают руками. Нормальная библиотека не выпустит токен с alg: none без особого флага, поэтому тестовый токен склеивают сами: base64url от {"alg":"none"}, точка, base64url от claims с ролью администратора, точка и пустая подпись. Ровно так делает функция sign в примерах выше, когда секрета нет. Тест отправляет такой токен в настоящий эндпоинт сервиса, а не в функцию проверки, и ждёт 401: так он ловит и случай, когда проверку забыли подключить к маршруту. Рядом стоит тест на HMAC, подписанный текстом открытого ключа из JWKS.

Контрактные тесты на aud между сервисами. Проверка aud ломается на стыке: сервис платежей ждёт payments-service, а сервер авторизации кладёт в токен имя клиента. Каждая сторона по отдельности права, а вместе либо всё отклоняется, либо кто-то выключает проверку aud. Помогает общий контракт: таблица «какой клиент для какого сервиса получает токен с каким aud». На стороне сервера авторизации тест запрашивает токен для каждого клиента и сверяет aud с контрактом. На стороне каждого сервиса тест берёт токен с правильной подписью, но с aud соседа (поддержки для платежей, платежей для заказов) и ждёт 401. Как токены ходят между сервисами, разобрано в статье Вызовы между сервисами, а общий подход к тестам безопасности - в статье Как искать уязвимости.

Ревью: что искать в диффе.

  • Чтение токена без проверки: jwt.decode( в Node, ParseUnverified в golang-jwt, verify_signature: False в PyJWT. Допустимо только для чтения заголовка перед проверкой, и результат не должен уходить в решение о доступе.
  • Вызов проверки без явного списка алгоритмов, либо список, где рядом стоят HS256 и RS256 для одного ключа.
  • Выключенные проверки claims: ignoreExpiration, WithoutClaimsValidation, verify_exp: False, отсутствие audience и issuer.
  • kid в строке пути, в тексте SQL, в URL; любой HTTP-запрос по адресу из заголовка токена.
  • Секрет HS256 в конфиге как читаемое слово или короче 32 байт; один секрет на несколько сервисов.
  • Допуск по часам больше нескольких минут, exp в часах и сутках у access token.
  • Токен в query-параметре (?access_token=) и Authorization в логах запросов.
  • Claims, которые читаются раньше проверки подписи: sub для выбора шарда, role для маршрутизации, iss для выбора ключа без проверки, что ключ принадлежит этому издателю.
  • Ключ из заголовка jwk или x5c, а не из своего набора.
  • Сравнение подписи через ==, equals или ===.

Сам токен. Раскодируйте боевой access token (заголовок и claims читаются без ключа) и проверьте, что в нём есть exp, iss, aud с именем сервиса и разумный срок жизни. Если aud нет, получатели не могут его проверить, и чинить надо на стороне сервера авторизации.

Ротация на тестовом стенде. Шаги из раздела про ротацию проверяются сценарием: добавили новый ключ в JWKS - старые токены проходят; переключили подпись - проходят и старые, и новые; убрали старый ключ - старые получают 401, новые проходят. Если на втором шаге сервис отвечает 401 на токен с новым kid, он не перечитывает набор при незнакомом kid.

Набор ключей издателя. Откройте jwks_uri из метаданных сервера авторизации: у каждого ключа должен быть свой kid (RFC 7517, раздел 4.5), в наборе только открытые части, и ключей в нём немного: текущий, следующий и недавно выведенный. Ключ, который висит в наборе годами, значит, что ротации нет.

Типичные ошибки

  • «Подпись проверяет шлюз, а сервис просто раскодирует». Сервис, до которого можно достучаться мимо шлюза, принимает любой токен. Подпись проверяет каждый, кто по токену решает о доступе.
  • Одна функция проверки на два алгоритма. «Поддержим и HS256 для внутренних сервисов, и RS256 для пользователей» с одним ключом - это ровно подмена алгоритма. У каждого ключа свой алгоритм.
  • Проверка aud выключена, потому что «токен свой». Токен поддержки, выданный тем же сервером авторизации, подписан тем же ключом. Отличить его можно только по aud.
  • Старый ключ удалён из JWKS в день ротации. Все токены, подписанные им и ещё живые, разом получают 401, и покупатели вылетают из корзины. Старый ключ держат в наборе, пока не истечёт последний его токен.
  • Допуск по часам «чтобы не мешал» в минуты и часы. Токен, который должен был умереть, продолжает работать.
  • Секрет secret, changeme или имя проекта. Перебирается офлайн по одному перехваченному токену, и дальше атакующий выпускает токены сам.
  • Ссылка на скачивание счёта с токеном в query. Адрес уезжает в журнал прокси, в историю браузера и в Referer; для такой ссылки нужен отдельный короткий одноразовый токен, а не access token.
Дополнительно: при первом чтении можно пропустить

Глубже: kid, JWKS и jkuрасширенноеспросят на собеседовании

Правильная схема поиска ключа выглядит так. Сервис при старте читает метаданные своего издателя и берёт из них jwks_uri, загружает набор ключей и держит его в памяти, проиндексировав по kid. Токен с kid, которого нет в наборе, вызывает одну перезагрузку JWKS (издатель мог повернуть ключи), но не чаще раза в несколько минут: иначе поток токенов со случайными kid превращается в поток запросов к серверу авторизации. Так, например, устроен createRemoteJWKSet в jose: повторная загрузка при неизвестном ключе срабатывает только после cooldownDuration. Найденный ключ хранит и свой алгоритм, и проверка идёт этим алгоритмом, а не тем, что написан в заголовке.

Заголовки jwk и x5c (RFC 7515, разделы 4.1.3 и 4.1.6) несут ключ или цепочку сертификатов прямо в токене. Для проверки подписи access token они бесполезны по той же причине, что и jku: ключ, присланный отправителем, ничего не доказывает. Если библиотека умеет брать ключ из них, эту возможность в сервисе выключают или не передают ей такой источник ключей.

jku и x5u в такой схеме не участвуют вовсе. RFC 7515 требует, чтобы JWKS по jku загружался по TLS, но TLS доказывает только подлинность сайта атакующего. Если ключи от нескольких издателей правда нужны, каждого издателя с его jwks_uri перечисляют в конфигурации, а токен лишь называет издателя через iss, и RFC 8725, раздел 3.8 требует проверить, что ключ принадлежит именно ему.

Если kid всё же уходит в базу (ключи арендаторов хранятся в таблице), его подставляют параметром запроса, а не склеивают в строку; подробнее о параметризации в разборе SQL-инъекции.

Глубже: короткий срок вместо отзыварасширенное

Подписанный токен нельзя отозвать без возврата к хранилищу: в этом его смысл. Поэтому основная защита от украденного access token - его короткий срок, а долгую сессию держит refresh token, который сервер авторизации может отозвать. Выбор между сессией и JWT и сроки токенов разобраны в паттернах авторизации, а где хранить токены в браузере - в статье хранение токенов на клиенте.

Отсюда два практических правила для проверяющего сервиса. Он не продлевает жизнь токену сам: допуск по часам остаётся секундами, а exp не заменяют проверкой iat «за последние сутки». И он не делает токен долгоживущим косвенно: не кладёт его в кеш ответов, ссылки и очереди, откуда тот достанется кому-то после выхода пользователя.

Коротко

  • Заголовок и claims JWT - base64url, не шифрование. До проверки подписи всё в токене пишет атакующий, включая alg, kid и jku.
  • alg в заголовке выбирает тот, кто прислал токен. Алгоритм закрепляют на стороне проверяющего: список из одного-двух значений, без none, один алгоритм на ключ (RFC 8725, 3.1).
  • Подмена RS256 на HS256 подписывает токен открытым ключом как секретом HMAC; закрытый список алгоритмов её отсекает.
  • kid только ищут в своём наборе ключей, jku и x5u из токена не используют: ключи приходят по jwks_uri из конфигурации.
  • Ротация: новый ключ сначала публикуют в JWKS, потом подписывают им, старый держат до истечения последнего его токена (RFC 7517, 4.5; OpenID Connect Core, 10.1.1).
  • Токен другого назначения (ID token, ссылка сброса пароля) отсекают aud и typ: правила для разных видов JWT одного издателя взаимоисключающие (RFC 8725, 3.12).
  • После подписи проверяют все claims: exp обязателен, iss свой, aud содержит имя сервиса. Без aud токен поддержки проходит в платежи.
  • Допуск по часам - секунды; секрет HS256 - не меньше 256 случайных бит, а при нескольких проверяющих лучше асимметричная подпись.
  • Функции decode без проверки не участвуют в решении о доступе; подпись сравнивают за постоянное время.
  • Токен не передают в адресе и не пишут в лог; от кражи защищает короткий срок access token и отзыв refresh.
  • В бою JWT проверяет библиотека с явным списком алгоритмов, издателем и аудиторией, а тесты на отказ (none, чужой ключ, чужой aud, истёкший exp) живут в CI. Токен с none собирают в тесте руками и шлют в настоящий эндпоинт; aud между сервисами держит контрактный тест.

Что почитать дальше