本文へスキップ
ecdsa.com
← すべての記事

ダウンタイムなしの JWKS 鍵ローテーション

ローテーションの失敗には 2 つのパターンがあります。検証者がまだ取得していない鍵で署名されたトークンを拒否するか、すでに取り下げた鍵を受け入れ続けるかです。どちらもタイミングの問題で、どちらも測定できます。この記事では、ローテーションのタイムライン、7 つの検証ライブラリのキャッシュ動作、そして大手プロバイダー 4 社が実際に送信している内容を紹介します。

要点:新しい鍵は、その鍵で何かに署名する前に JWKS で公開し、少なくとも検証者のキャッシュの有効期間 1 回分が丸ごと経過するまで待ってください。そのうえで、署名を新しい鍵に切り替えてください。古い鍵は、その鍵で署名した最後のトークンが期限切れになり、さらに時刻のずれの許容分とキャッシュの有効期間 1 回分が経過するまで、公開したままにしてください。すべての鍵に、決して再利用しない kid を付けてください。緊急時には、鍵を削除したうえで検証者に鍵セットを再取得させてください。再取得されるまでは削除した鍵が引き続き信頼され、その期間の長さは、最も遅い検証者のキャッシュの有効期間で決まります。

検証者が鍵を見つける仕組み

発行者は、OpenID Connect のディスカバリドキュメントまたは OAuth 認可サーバーのメタデータに記載した jwks_uri で、自身の公開鍵を JWK セットとして公開します。各トークンは、ヘッダーに kid を持っています。検証者は鍵セットを取得してキャッシュし、kid が一致する鍵を選びます。OpenID Connect Core は、ローテーションを 4 つの文で説明しています(§10.1.1)。署名者は新しい鍵を鍵セットに追加し、任意の時点でそのうちの 1 つを使い始め、kid によって変更を知らせます。そして検証者は「見慣れない kid の値を見たら、jwks_uri の場所に戻って鍵を再取得すべきことを知っている」とされています。また鍵セットは、「最近使用を終えた署名鍵を、妥当な期間保持すべきである(SHOULD)」とされています。

この仕組みからは、仕様が明示していない 2 つの帰結が導かれます。

  • 未知の kid は取得のきっかけになりますが、検証者はその取得にレート制限をかけています。制限がなければ、ランダムな kid の値を持つトークンを送るだけで、誰でも API にリクエストのたびに JWKS を取得させることができてしまいます。制限があると、検証者が最後に確認した時点でまだ公開されていなかった鍵で署名されたトークンは、制限が解けるまで拒否されます。
  • 既知の kid が取得のきっかけになることはありません。鍵セットから鍵を削除しても、検証者のキャッシュが自然に期限切れになるまでは何も起きません。キャッシュミスのときに再取得するロジックでは、削除に気付けません。削除された鍵は、キャッシュの中から消えていないからです。

タイムラインの実測

次のテストハーネスは、1 つのプロセスの中で発行者 1 つと検証者 2 つを動かします。発行者は HTTP で JWKS を配信し、検証者は jose の createRemoteJWKSet を使います。キャッシュは 4 秒、未知の kid による再取得の間隔(クールダウン)は 2 秒です。実際の環境では分単位や時間単位の値を使いますが、挙動のパターンは同じです。

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)

ここで起きた 3 つのことは、本番環境でも起こります。

  1. 計画的なローテーションは失敗ゼロで成功しました。B で署名された最初のトークンが届く前に、B がすでに検証者のキャッシュに入っていたためです。未知の kid による再取得は必要ありませんでした。
  2. 公開前に署名すると失敗し、修正した後も失敗し続けました。検証者は未知の kid を受けて鍵セットを再取得しましたが C は見つからず、その後は、その時点で C がすでに公開されていたにもかかわらず、クールダウンの間は再取得を拒否しました。jose のデフォルトのクールダウンである 30 秒なら、検証者のインスタンスごとに 30 秒間、リクエストが拒否されます。keyfunc のデフォルトの 5 分なら、それが 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 の後)古い鍵を鍵セットから削除し、対応する秘密鍵を破棄

具体例で考えます。検証者に ASP.NET Core の API が含まれ(デフォルトではメタデータを 12 時間ごとに再取得します。後述)、JWKS の手前に有効期間 1 時間の Nginx キャッシュがあり、アクセストークンの有効期間が 1 時間、許容する時刻のずれが 5 分のプロバイダーだとします。C は 13 時間なので、新しい鍵は月曜日の朝に鍵セットに追加し、月曜日の夕方に署名に使い始めます。古い鍵は火曜日の朝に削除できます。必要な期間は 1 分ではなく 1 日であり、この期間を長くするコストは、鍵セットの鍵が 1 つ増えることだけです。

検証者に JWKS の URL ではなく固定の鍵を設定している場合、待つべき C はありません。その代わり、ローテーションにはすべての検証者で足並みをそろえた設定変更が必要になります。どの OpenID Connect ライブラリでも JWKS の URL がデフォルトになっているのは、このためです。

kid のルール

  • 一意で、決して再利用しない:RFC 7517 §4.5 は、1 つの鍵セット内の鍵は「異なる『kid』の値を使うべきである(SHOULD)」としています。時間をまたいだ再利用は、1 つの鍵セット内での重複よりも深刻です。kid ごとにキャッシュする検証者はその名前で古い鍵を保持し続けますし、jose が再取得するのは一致する鍵がないときだけで、一致した鍵で検証に失敗したときではありません。そのため、新しい鍵による署名は、キャッシュが期限切れになるまで検証に失敗します。
  • 検証者にとって意味を持たない値にする:ランダムな値、2026-11-b のような日付ラベル、または RFC 7638 の JWK サムプリントを使ってください。検証者が信頼すべき情報は何も埋め込まないでください。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 というエラーで失敗します。したがって、鍵が 2 つある JWKS と kid のないトークンの組み合わせは、まさにローテーションのたびに動かなくなります。
  • alg と組み合わせる:RFC 8725 §3.1 が求めているとおり、検証者が鍵を 1 つのアルゴリズムに結び付けられるよう、各鍵に alg を付けて公開してください。

検証ライブラリの実際の挙動

上の表の C は、発行者側ではなく検証者側で決まります。検証者を自作する場合は、Microsoft の署名鍵のロールオーバーに関するガイダンスが妥当な基準になります。鍵を kid ごとに個別に 24 時間キャッシュし、バックグラウンドで 1 時間ごとに再取得し、未知の kid を受け取ったときにも再取得しますが、その頻度は 5 分に 1 回までに抑えます。以下のライブラリのデフォルト値は、記載したバージョンについて、各ライブラリのソースコード、または定数を出力するプログラムで確認したものです。

ライブラリキャッシュの有効期間未知の kid での再取得強制的な再取得
jose 6.2.13 createRemoteJWKSet10 分30 秒に最大 1 回jwks.reload()
jwks-rsa 4.1.010 分(kid ごと)キャッシュミスのたびに実行(rateLimit: true の場合は 1 分に 10 回まで)新しいクライアントを作成
PyJWT 2.15.1 PyJWKClient300 秒30 秒に最大 1 回get_jwk_set(refresh=True)
keyfunc v3.8.2(Go)バックグラウンドで 1 時間ごとに再取得5 分に最大 1 回Storage().KeyDelete(ctx, kid) で鍵を 1 つ削除
Nimbus JOSE+JWT 10.10 JWKSourceBuilder のデフォルト5 分(期限の 30 秒前に再取得)30 秒に最大 1 回新しいソースを構築
Microsoft.IdentityModel 8.23.0 ConfigurationManager(ASP.NET Core)12 時間5 分に最大 1 回RequestRefresh()(これ自体も 5 分に 1 回まで)
go-oidc v3.21.0 RemoteKeySet期限なしキャッシュミスのたびに実行(同時に進行する取得は 1 つ)再起動

Spring Security 7.1.1 は、NimbusJwtDecoder のソースを JWKSourceBuilder で構築しますが、事前の再取得とレート制限を無効にしています(JwkSetUriJwtDecoderBuilder の .refreshAheadCache(false) と .rateLimited(false))。そのため、これらのデフォルトのうち残るのは 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 は、検証に失敗するたびにレート制限なしで鍵セットを再取得し、同時に発生した取得を 1 つにまとめるだけです。原因が偽造されたトークンでも、まだ公開されていない鍵でも、すでに削除された鍵でも、失敗は同じ failed to verify id token signature として表れます。

jose、jwks-rsa、PyJWT、keyfunc のいずれも、JWKS レスポンスの Cache-Control ヘッダーを読みません。ソースコードにも、このヘッダーへの参照はありません。効くのは検証者自身の設定であり、それを発行者側から変えることはできません。

JWKS エンドポイントの Cache-Control

それでも Cache-Control には意味があります。エンドポイントの手前にある CDN やリバースプロキシ、そしてプロバイダーの推奨に従う検証者に影響するからです。たとえば Google は開発者に対し、「レスポンスの Cache-Control ヘッダーを調べて、いつ再取得すべきかを判断する」よう求めています(Google Identity)。次のスクリプトで、大手発行者 4 社とサンプルテナント 1 つが 2026年10月20日に返した内容を取得しました。

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、2026年10月20日)
== 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)から 1 日(Microsoft)までで、どの発行者も複数の鍵を公開していました。ローテーションを日常的に行っている鍵セットは、このような形になります。応答はリクエストごとにも変わります。その 30 分前には、Microsoft のエンドポイントに 8 個の鍵が載っており、Google の max-age は 22930 秒でした。つまり、max-age に従う検証者は、取得のたびに異なるキャッシュの有効期間を得ることになります。

自分のエンドポイントについては、次の点に注意してください。

  • max-age は、ローテーションの期間から決めてください(その逆ではありません)。どの値を送っても、共有キャッシュはその期間だけ鍵セットを保持し、そのキャッシュを経由する未知の kid による再取得は古いコピーを受け取ります。max-age が 1 時間なら、上記の手順 2 は少なくとも 1 時間待たなければなりません。
  • 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 では、再起動が唯一の確実な方法です。すべての検証者に手が届かない場合は、それらのキャッシュの有効期間が、そのまま危険にさらされる期間になります。ASP.NET Core のデフォルトなら 12 時間、go-oidc なら無期限です。
  3. JWKS エンドポイントの手前にあるすべてのキャッシュをパージしてください。古い鍵セットを配信し続ける CDN があると、手順 2 が無駄になります。
  4. その鍵で署名されたものは、すべて疑ってください。危険にさらされていた期間に発行されたリフレッシュトークンとセッションを取り消し、危殆化した kid を持つトークンのうち、自分の発行者が発行していないものがないかログを確認してください。

この手順は練習しておいてください。緊急時のローテーションは、計画的なローテーションから待ち時間を取り除いたものです。計画的なローテーションを自動化して数週間ごとに実行していれば、緊急時の手順は即興の作業ではなく、使い慣れたスクリプトになります。NIST SP 800-57 Part 1 Rev. 5 §5.3.6 は、署名用の秘密鍵の暗号期間の上限として「約 1〜3 年」を示していますが、これは上限であって目標ではありません。

チェックリスト

  • 鍵セット内のすべての鍵に、一意の kid、alg、"use": "sig" が付いています。秘密鍵のメンバーや共通鍵は含まれていません。
  • トークンには常に kid が付いています。
  • 新しい鍵は、署名に使い始める少なくとも C 前に公開し、古い鍵は、最後の署名から少なくとも L + S + C の間、公開したままにしています。
  • JWKS エンドポイントは、意図して設定した Cache-Control を stale-if-error 付きで送信し、空の鍵セットをステータス 200 で返すことはなく、その URL も変わりません。
  • 運用しているすべての検証者について、キャッシュの有効期間と、強制的に再取得させる方法がわかっています。
  • 緊急時の手順が文書化されており、実際に実行したことがあります。

出典