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

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

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

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

Как проверяющая сторона находит ключ

Издатель публикует свои открытые ключи в виде набора JWK (JWK Set) по адресу jwks_uri, который указан в его документе обнаружения OpenID Connect или в метаданных сервера авторизации OAuth. Каждый токен несёт в заголовке kid. Проверяющая сторона загружает набор, кеширует его и выбирает ключ с совпадающим kid. OpenID Connect Core описывает ротацию в четырёх предложениях (§ 10.1.1): подписант добавляет в набор новые ключи, начинает использовать один из них, когда сочтёт нужным, сообщает о смене через kid, а проверяющая сторона «понимает, что нужно вернуться по адресу jwks_uri и заново получить ключи, когда видит незнакомое значение kid». В наборе «следует (SHOULD) сохранять недавно выведенные из эксплуатации ключи подписи в течение разумного времени».

У этого механизма есть два следствия, о которых спецификация прямо не говорит:

  • Неизвестный kid вызывает загрузку набора, но проверяющие стороны ограничивают частоту таких загрузок. Без ограничения кто угодно мог бы заставить ваш API загружать JWKS на каждый запрос, присылая токены со случайными значениями kid. С ограничением токен, подписанный ключом, который ещё не был опубликован, когда проверяющая сторона в последний раз заглядывала в набор, отклоняется до конца интервала ограничения.
  • Известный kid никогда не вызывает загрузку. Удаление ключа из набора ничего не меняет, пока кеш проверяющей стороны не устареет сам. Логика обновления при промахе не может заметить удаление, потому что удалённый ключ в кеше по-прежнему есть.

Временная шкала по замерам

Тестовая программа ниже запускает издателя и две проверяющие стороны в одном процессе. Издатель отдаёт JWKS по HTTP; проверяющие стороны используют createRemoteJWKSet из jose с кешем на 4 секунды и периодом ожидания (cooldown) 2 секунды между обновлениями при промахе по kid. В реальных системах это минуты или часы, но картина та же.

rotation.mjs (jose 6.2.13, Node.js 24.22)
// Rotation timeline against a live JWKS endpoint, verified with jose's createRemoteJWKSet.
// Durations are shortened (cache 4 s, cooldown 2 s) so the whole run takes seconds.
import { createServer } from "node:http";
import { SignJWT, createRemoteJWKSet, exportJWK, generateKeyPair, jwtVerify } from "jose";

const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const t0 = Date.now();
const log = (msg) => console.log(`t=${((Date.now() - t0) / 1000).toFixed(1).padStart(4)}s  ${msg}`);

// Issuer: three keys, the currently published kids, and a count of JWKS fetches.
const keys = {};
for (const kid of ["2026-10-a", "2026-11-b", "2026-12-c"]) {
  const pair = await generateKeyPair("ES256");
  keys[kid] = { ...pair, jwk: { ...(await exportJWK(pair.publicKey)), kid, alg: "ES256", use: "sig" } };
}
let published = ["2026-10-a"];
let fetches = 0;
const server = createServer((req, res) => {
  fetches++;
  res.setHeader("content-type", "application/jwk-set+json");
  res.setHeader("cache-control", "public, max-age=4");
  res.end(JSON.stringify({ keys: published.map((kid) => keys[kid].jwk) }));
}).listen(8090);
const sign = (kid) =>
  new SignJWT({ sub: "user-42" }).setProtectedHeader({ alg: "ES256", kid }).setExpirationTime("10m").sign(keys[kid].privateKey);

// Two verifiers with identical settings; only the first one is told to reload.
const remote = () =>
  createRemoteJWKSet(new URL("http://127.0.0.1:8090/jwks.json"), { cacheMaxAge: 4000, cooldownDuration: 2000 });
const verifiers = { api1: remote(), api2: remote() };
const verify = async (name, label, token) => {
  const before = fetches;
  let result = "accepted";
  try {
    await jwtVerify(token, verifiers[name], { algorithms: ["ES256"] });
  } catch (e) {
    result = e.code;
  }
  log(`${name}  ${label.padEnd(40)} ${result} (fetches +${fetches - before})`);
};

const tokenA = await sign("2026-10-a");
await verify("api1", "token A, set {A}", tokenA);

log("--- planned rotation: publish B, wait one cache lifetime, then sign with B");
published = ["2026-10-a", "2026-11-b"];
await sleep(4200);
await verify("api1", "token A after the cache expired", tokenA);
await verify("api1", "token B", await sign("2026-11-b"));

log("--- mistake: sign with C before publishing it");
await sleep(2200);
const tokenC = await sign("2026-12-c");
await verify("api1", "token C, C not published", tokenC);
published = ["2026-10-a", "2026-11-b", "2026-12-c"];
log("issuer publishes C");
await verify("api1", "token C, inside the cooldown", tokenC);
await sleep(2200);
await verify("api1", "token C, after the cooldown", tokenC);

log("--- emergency: A is compromised and removed");
await verify("api2", "token A before removal", tokenA);
published = ["2026-11-b", "2026-12-c"];
await verify("api1", "token A right after removal", tokenA);
const before = fetches;
await verifiers.api1.reload();
log(`api1  reload()                                   (fetches +${fetches - before})`);
await verify("api1", "token A after reload()", tokenA);
for (let i = 0; i < 4; i++) {
  await sleep(1100);
  await verify("api2", "token A, no reload", tokenA);
}
server.close();
Вывод (node:24.22.0-bookworm-slim)
t= 0.0s  api1  token A, set {A}                         accepted (fetches +1)
t= 0.0s  --- planned rotation: publish B, wait one cache lifetime, then sign with B
t= 4.2s  api1  token A after the cache expired          accepted (fetches +1)
t= 4.3s  api1  token B                                  accepted (fetches +0)
t= 4.3s  --- mistake: sign with C before publishing it
t= 6.5s  api1  token C, C not published                 ERR_JWKS_NO_MATCHING_KEY (fetches +1)
t= 6.5s  issuer publishes C
t= 6.5s  api1  token C, inside the cooldown             ERR_JWKS_NO_MATCHING_KEY (fetches +0)
t= 8.7s  api1  token C, after the cooldown              accepted (fetches +1)
t= 8.7s  --- emergency: A is compromised and removed
t= 8.7s  api2  token A before removal                   accepted (fetches +1)
t= 8.7s  api1  token A right after removal              accepted (fetches +0)
t= 8.7s  api1  reload()                                   (fetches +1)
t= 8.7s  api1  token A after reload()                   ERR_JWKS_NO_MATCHING_KEY (fetches +0)
t= 9.8s  api2  token A, no reload                       accepted (fetches +0)
t=10.9s  api2  token A, no reload                       accepted (fetches +0)
t=12.0s  api2  token A, no reload                       accepted (fetches +0)
t=13.1s  api2  token A, no reload                       ERR_JWKS_NO_MATCHING_KEY (fetches +1)

Здесь произошли три вещи, которые произойдут и в рабочей среде:

  1. Плановая ротация прошла без единого сбоя, потому что ключ B оказался в кеше проверяющей стороны раньше, чем пришёл первый токен, подписанный B. Обновление при промахе по kid не понадобилось.
  2. Подпись до публикации привела к ошибкам, и они не прекратились после исправления. Проверяющая сторона обновила набор из-за неизвестного kid, не нашла C, а затем отказывалась обновлять его снова, пока шёл период ожидания, хотя к тому моменту C уже был опубликован. При периоде ожидания jose по умолчанию в 30 секунд это полминуты отклонённых запросов на каждый экземпляр проверяющей стороны, а при значении по умолчанию keyfunc в 5 минут — пять минут. API записывает в журнал ошибку no applicable key found in the JSON Web Key Set, и по ней этот случай ничем не отличается от поддельного токена.
  3. Удаление ключа не отозвало его. api2 продолжал принимать удалённый ключ A, пока не устарел кеш этой проверяющей стороны. Сразу перестала его принимать только та проверяющая сторона, которой велели выполнить reload().

Правила ротации без простоя

Пусть C — наибольшее время, в течение которого любая проверяющая сторона или любой кеш перед эндпоинтом JWKS может хранить набор; L — наибольший срок жизни токенов, которые вы подписываете; S — допустимое расхождение часов у проверяющих сторон.

ШагКогдаЧто сделать
1. ПубликацияT₀Добавьте новый ключ в набор с новым kid, его alg и "use": "sig". Продолжайте подписывать старым ключом.
2. ПереключениеT₀ + CНачните подписывать новым ключом. У каждой проверяющей стороны уже была возможность его загрузить.
3. Вывод из использованияT₀ + C + L + SСрок действия последнего токена, подписанного старым ключом, истёк везде.
4. Удалениепосле шага 3, плюс C, если нужно, чтобы кеши забыли ключУдалите старый ключ из набора и уничтожьте его закрытую часть.

Пример расчёта: у провайдера среди проверяющих сторон есть API на ASP.NET Core (по умолчанию они обновляют метаданные каждые 12 часов, см. ниже), перед JWKS стоит кеш Nginx с временем жизни в один час, токены доступа действуют один час, а допустимое расхождение часов составляет 5 минут. C составляет 13 часов, поэтому новый ключ попадает в набор в понедельник утром, а подписывать начинает в понедельник вечером. Старый ключ можно убрать во вторник утром. Окно — сутки, а не минута, и единственная цена его удлинения — один лишний ключ в наборе.

Если проверяющим сторонам вместо URL набора JWKS задан статический ключ, ждать C не нужно, но тогда ротация требует согласованного изменения конфигурации на каждой проверяющей стороне. Именно поэтому во всех библиотеках OpenID Connect по умолчанию используется URL набора JWKS.

Правила для kid

  • Уникален и никогда не используется повторно. В RFC 7517, § 4.5 сказано, что ключам одного набора «следует (SHOULD) использовать разные значения „kid“». Повторное использование значения в разное время хуже, чем дубликат внутри набора: проверяющая сторона, которая кеширует ключи по kid, хранит под этим именем старый ключ, а jose обновляет набор, только когда не совпал ни один ключ, но не когда подпись не прошла проверку совпавшим ключом. В итоге подписи нового ключа не проходят проверку, пока кеш не устареет.
  • Не несёт смысла для проверяющих сторон. Используйте случайное значение, метку с датой вроде 2026-11-b или отпечаток JWK по RFC 7638. Не кодируйте в нём ничего, чему проверяющие стороны должны доверять: kid приходит в непроверенном заголовке.
  • Присутствует всегда. Без kid проверяющей стороне приходится перебирать все ключи или сдаваться. jwks-rsa отказывает, если в наборе больше одного ключа (No KID specified and JWKS endpoint returned more than 1 key), а jose выдаёт ошибку multiple matching keys found in the JSON Web Key Set. Поэтому JWKS с двумя ключами в сочетании с токенами без kid ломается ровно во время каждой ротации.
  • Идёт в паре с alg. Указывайте alg у каждого ключа, чтобы проверяющие стороны привязывали ключ к одному алгоритму, как требует RFC 8725, § 3.1.

Как на самом деле ведут себя библиотеки проверки

Значение C в таблице выше задают проверяющие стороны, а не вы. Для самописных проверяющих сторон разумная отправная точка — рекомендации Microsoft по смене ключей подписи: кешировать ключи по отдельности по kid на 24 часа, обновлять их в фоне каждый час и обновлять при неизвестном kid, но не чаще раза в 5 минут. Значения по умолчанию в таблице ниже взяты из исходного кода каждой библиотеки или из программы, которая печатает её константы, для указанных версий:

БиблиотекаВремя жизни кешаОбновление при неизвестном kidПринудительное обновление
jose 6.2.13 createRemoteJWKSet10 минутне чаще раза в 30 сjwks.reload()
jwks-rsa 4.1.010 минут, для каждого kidпри каждом промахе, если не задан rateLimit: true (тогда 10 в минуту)создать новый клиент
PyJWT 2.15.1 PyJWKClient300 сне чаще раза в 30 сget_jwk_set(refresh=True)
keyfunc v3.8.2 (Go)фоновое обновление каждый часне чаще раза в 5 минутStorage().KeyDelete(ctx, kid) удаляет один ключ
Nimbus JOSE+JWT 10.10 JWKSourceBuilder, значения по умолчанию5 минут, обновление за 30 с до истеченияне чаще раза в 30 ссобрать новый источник
Microsoft.IdentityModel 8.23.0 ConfigurationManager (ASP.NET Core)12 часовне чаще раза в 5 минутRequestRefresh(), который сам ограничен одним вызовом в 5 минут
go-oidc v3.21.0 RemoteKeySetбез истечения срокапри каждом промахе, не больше одной загрузки одновременноперезапуск

Spring Security 7.1.1 собирает источник ключей для NimbusJwtDecoder через JWKSourceBuilder, но отключает упреждающее обновление и ограничение частоты запросов (.refreshAheadCache(false), .rateLimited(false) в JwkSetUriJwtDecoderBuilder), так что от этих значений по умолчанию остаётся только 5-минутный кеш.

Последняя строка выбивается из ряда. go-oidc хранит набор ключей, пока проверка какого-нибудь токена по этому набору не завершится неудачей; по таймеру ничего не истекает. Вот что это значит для аварийного удаления:

main.go (go-oidc v3.21.0, go-jose v4.1.6, Go 1.27.3)
package main

// Emergency removal against go-oidc's RemoteKeySet: the key set has no time-based expiry,
// so a removed key keeps verifying until the process restarts or a kid miss forces a fetch.

import (
	"context"
	"crypto/ecdsa"
	"crypto/elliptic"
	"crypto/rand"
	"encoding/json"
	"fmt"
	"net"
	"net/http"
	"sync/atomic"
	"time"

	"github.com/coreos/go-oidc/v3/oidc"
	jose "github.com/go-jose/go-jose/v4"
)

func main() {
	keyA, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
	keyB, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
	jwkA := jose.JSONWebKey{Key: &keyA.PublicKey, KeyID: "2026-10-a", Algorithm: "ES256", Use: "sig"}
	jwkB := jose.JSONWebKey{Key: &keyB.PublicKey, KeyID: "2026-11-b", Algorithm: "ES256", Use: "sig"}

	var published atomic.Value
	published.Store([]jose.JSONWebKey{jwkA, jwkB})
	var fetches atomic.Int32
	ln, _ := net.Listen("tcp", "127.0.0.1:8092")
	go http.Serve(ln, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		fetches.Add(1)
		json.NewEncoder(w).Encode(jose.JSONWebKeySet{Keys: published.Load().([]jose.JSONWebKey)})
	}))

	sign := func(key *ecdsa.PrivateKey, kid string) string {
		signer, _ := jose.NewSigner(jose.SigningKey{Algorithm: jose.ES256, Key: key},
			(&jose.SignerOptions{}).WithHeader("kid", kid))
		claims, _ := json.Marshal(map[string]any{
			"iss": "https://id.example.com", "aud": "app", "sub": "user-42",
			"exp": time.Now().Add(10 * time.Minute).Unix(),
		})
		jws, _ := signer.Sign(claims)
		out, _ := jws.CompactSerialize()
		return out
	}
	tokenA := sign(keyA, "2026-10-a")

	ctx := context.Background()
	keySet := oidc.NewRemoteKeySet(ctx, "http://127.0.0.1:8092/jwks.json")
	verifier := oidc.NewVerifier("https://id.example.com", keySet, &oidc.Config{
		ClientID: "app", SupportedSigningAlgs: []string{"ES256"},
	})
	start := time.Now()
	check := func(label string) {
		before := fetches.Load()
		_, err := verifier.Verify(ctx, tokenA)
		result := "accepted"
		if err != nil {
			result = err.Error()
		}
		fmt.Printf("t=%4.1fs  %-34s %s (JWKS fetches +%d)\n", time.Since(start).Seconds(), label, result, fetches.Load()-before)
	}

	check("token A, set {A,B}")
	published.Store([]jose.JSONWebKey{jwkB})
	fmt.Printf("t=%4.1fs  A removed from the set\n", time.Since(start).Seconds())
	check("token A right after removal")
	time.Sleep(5 * time.Second)
	check("token A 5 s later")
	// Any token with an unknown kid makes RemoteKeySet fetch the set again.
	other, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
	before := fetches.Load()
	_, err := verifier.Verify(ctx, sign(other, "unknown-kid"))
	fmt.Printf("t=%4.1fs  %-34s %v (JWKS fetches +%d)\n", time.Since(start).Seconds(), "token with an unknown kid", err, fetches.Load()-before)
	check("token A after that refetch")
}
Вывод (golang:1.27.3-trixie)
t= 0.0s  token A, set {A,B}                 accepted (JWKS fetches +1)
t= 0.0s  A removed from the set
t= 0.0s  token A right after removal        accepted (JWKS fetches +0)
t= 5.0s  token A 5 s later                  accepted (JWKS fetches +0)
t= 5.0s  token with an unknown kid          failed to verify signature: failed to verify id token signature (JWKS fetches +1)
t= 5.0s  token A after that refetch         failed to verify signature: failed to verify id token signature (JWKS fetches +1)

Удалённому ключу продолжали доверять, пока загрузку не вызвало что-то другое. В малонагруженном сервисе это могут быть дни. Кроме того, go-oidc загружает набор заново при каждой неудачной проверке, без ограничения частоты запросов, и лишь объединяет одновременные загрузки в одну. Сбой в любом случае выглядит одинаково — failed to verify id token signature, будь причиной поддельный токен, ещё не опубликованный ключ или уже удалённый.

Ни jose, ни jwks-rsa, ни PyJWT, ни keyfunc не читают заголовок Cache-Control в ответе JWKS: в их исходном коде он даже не упоминается. Значение имеет только собственная настройка проверяющей стороны, а со стороны издателя её не изменить.

Cache-Control на эндпоинте JWKS

Cache-Control всё же важен: для CDN и обратных прокси перед эндпоинтом, а также для проверяющих сторон, которые следуют совету провайдера. Google, например, предлагает разработчикам «проверять заголовок Cache-Control в ответе, чтобы определить, когда загружать их снова» (Google Identity). Вот что 20 октября 2026 года отдали четыре крупных издателя и один демонстрационный тенант; данные получены этим скриптом:

headers.sh
#!/bin/sh
# Prints the caching headers and the number of keys of public JWKS endpoints.
for url in \
  https://www.googleapis.com/oauth2/v3/certs \
  https://login.microsoftonline.com/common/discovery/v2.0/keys \
  https://appleid.apple.com/auth/keys \
  https://token.actions.githubusercontent.com/.well-known/jwks \
  https://samples.auth0.com/.well-known/jwks.json
do
  echo "== $url"
  curl -s -D - -o /tmp/body.json "$url" | grep -iE '^(cache-control|expires):' | tr -d '\r'
  echo "keys: $(grep -o '"kid"' /tmp/body.json | wc -l)"
done
headers.sh: заголовки кеширования и число ключей (curlimages/curl:8.11.1, 20 окт. 2026 г.)
== https://www.googleapis.com/oauth2/v3/certs
expires: Tue, 20 Oct 2026 17:32:23 GMT
cache-control: public, max-age=21671, must-revalidate, no-transform
keys: 2
== https://login.microsoftonline.com/common/discovery/v2.0/keys
Cache-Control: max-age=86400, private
keys: 7
== https://appleid.apple.com/auth/keys
Cache-Control: no-store
keys: 3
== https://token.actions.githubusercontent.com/.well-known/jwks
cache-control: public, max-age=3600, must-revalidate
keys: 4
== https://samples.auth0.com/.well-known/jwks.json
cache-control: public, max-age=15, stale-while-revalidate=15, stale-if-error=86400
keys: 2

Скрипт выводит только заголовки Cache-Control и Expires и считает поля kid в теле ответа. Разброс — от «никогда не хранить» (Apple) до суток (Microsoft), и у каждого издателя было опубликовано больше одного ключа: так выглядит набор, когда ротация стала обычной процедурой. Ответы к тому же меняются от запроса к запросу: на полчаса раньше эндпоинт Microsoft отдавал восемь ключей, а max-age у Google составлял 22930 секунд, так что проверяющая сторона, которая учитывает max-age, при каждой загрузке получает другое время жизни кеша.

Для собственного эндпоинта:

  • Выбирайте max-age исходя из окна ротации, а не наоборот. Какое бы значение вы ни отправили, общий кеш будет хранить набор ровно столько, и обновление при промахе по kid, которое идёт через этот кеш, получит устаревшую копию. max-age в один час означает, что с шагом 2 из таблицы выше нужно подождать не меньше часа.
  • Добавьте stale-if-error. Если эндпоинт недоступен, проверяющие стороны и CDN, которые поддерживают эту директиву (RFC 5861), продолжают работать с последним исправным набором, а не отклоняют каждый запрос. Демонстрационный тенант Auth0 делает именно так.
  • Никогда не допускайте, чтобы эндпоинт возвращал пустой набор или страницу ошибки со статусом 200. jwks-rsa в таком случае выдаёт ошибку The JWKS endpoint did not contain any signing keys, а jose при ответе со статусом, отличным от 200, — ошибку Expected 200 OK from the JSON Web Key Set HTTP response.

Аварийное удаление

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

  1. Опубликуйте набор, в котором уже есть новый ключ и нет скомпрометированного, и сразу переключите подпись на новый ключ. Часть проверяющих сторон будет отклонять токены, подписанные новым ключом, пока у них не сработает обновление при промахе по kid; такова цена.
  2. Заставьте проверяющие стороны обновить набор. Вызовите метод принудительного обновления из таблицы выше или перезапустите процессы. Для go-oidc перезапуск — единственный надёжный способ. Если вы не можете добраться до каждой проверяющей стороны, время жизни их кеша и есть ваше окно уязвимости: 12 часов при настройках ASP.NET Core по умолчанию и без ограничения для go-oidc.
  3. Очистите все кеши перед эндпоинтом JWKS. CDN, который всё ещё отдаёт старый набор, сводит шаг 2 на нет.
  4. Считайте подозрительным всё, что подписано этим ключом. Отзовите токены обновления и сессии, выданные за время уязвимости, и поищите в журналах токены со скомпрометированным kid, которые ваш издатель не выпускал.

Отрабатывайте эту процедуру. Аварийная ротация — это плановая ротация без ожидания; если плановые ротации автоматизированы и проходят раз в несколько недель, аварийные действия сводятся к заранее известному скрипту, а не к импровизации. NIST SP 800-57 Part 1 Rev. 5, § 5.3.6 предлагает для закрытого ключа подписи максимальный криптопериод «примерно от одного до трёх лет»; это потолок, а не цель.

Чек-лист

  • У каждого ключа в наборе есть уникальный kid, alg и "use": "sig"; в наборе нет ни параметров закрытого ключа, ни симметричных ключей.
  • Токены всегда содержат kid.
  • Новые ключи публикуются не менее чем за C до первой подписи; старые остаются в наборе не менее L + S + C после последней подписи.
  • Эндпоинт JWKS отправляет осознанно выбранный Cache-Control с stale-if-error, никогда не возвращает пустой набор со статусом 200, а его URL никогда не меняется.
  • Для каждой проверяющей стороны, которой вы управляете, известны время жизни кеша и способ принудительного обновления.
  • Аварийная процедура записана и уже выполнялась на практике.

Источники