简短回答:在用新密钥签名任何内容之前,先把它发布到你的 JWKS 中,并至少等待一个完整的验证方缓存时长。然后把签名切换到新密钥。旧密钥要一直保持发布,直到它签名的最后一个令牌过期,再加上时钟偏差,再加上一个缓存时长。给每个密钥分配一个永不重复使用的 kid。紧急情况下,移除该密钥并让验证方刷新:在它们刷新之前,被移除的密钥仍然受信任,这段窗口的长度就是你最慢的验证方的缓存时长。
验证方如何找到密钥
颁发者把自己的公钥以 JSON Web 密钥集(JWK Set)的形式发布在 jwks_uri 上,这个地址列在它的 OpenID Connect 发现文档或 OAuth 授权服务器元数据中。每个令牌的头部都带有 kid。验证方获取密钥集并缓存,然后选出 kid 匹配的密钥。OpenID Connect Core 用四句话描述了轮换(§10.1.1):签名者向密钥集添加新密钥,在需要时开始使用其中一个,通过 kid 表明这一变化;验证方则“在看到不熟悉的 kid 值时,知道要回到 jwks_uri 所指的位置重新获取密钥”。密钥集“应当(SHOULD)在合理的时间内保留最近停用的签名密钥”。
这一机制有两个规范没有明说的推论:
- 未知的
kid会触发获取,但验证方会对这些获取做速率限制。如果没有限制,任何人都可以发送带随机kid值的令牌,让你的 API 在每个请求上都去获取 JWKS。有了限制,如果签名密钥在验证方上次查看时尚未发布,用它签名的令牌就会一直被拒绝,直到限制解除。 - 已知的
kid永远不会触发获取。在验证方的缓存自行过期之前,从密钥集中移除密钥不会产生任何效果。“未命中即刷新”的逻辑察觉不到移除,因为被移除的密钥并没有从缓存中消失。
实测时间线
下面的测试程序在一个进程中运行一个颁发者和两个验证方。颁发者通过 HTTP 提供 JWKS;验证方使用 jose 的 createRemoteJWKSet,缓存时长为 4 秒,两次 kid 未命中刷新之间的冷却时间为 2 秒。实际部署用的是几分钟或几小时,但整体形态相同。
// 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();
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)
这里发生的三件事,在生产环境中同样会发生:
- 计划内轮换零失败,因为在第一个用 B 签名的令牌到达之前,B 已经在验证方的缓存中了。也就不需要 kid 未命中刷新。
- 先签名后发布导致验证失败,并且在修正之后仍然失败。验证方因未知的
kid而刷新,没有找到 C,随后在冷却期内拒绝再次刷新,尽管此时 C 已经发布。按 jose 默认 30 秒的冷却时间,每个验证方实例会有半分钟的请求被拒绝;按 keyfunc 默认的 5 分钟,则是整整 5 分钟。API 日志里记录的错误是 no applicable key found in the JSON Web Key Set,与伪造令牌的表现一模一样。 - 移除密钥并没有撤销它。
api2一直接受被移除的密钥 A,直到自己的缓存过期。只有被要求执行reload()的验证方立即不再接受密钥 A。
零停机轮换的规则
设 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 小时,因此新密钥在周一早上加入密钥集,周一晚上开始签名。旧密钥可以在周二早上移出密钥集。整个窗口以天计,而不是以分钟计;把它拉长的唯一代价,只是密钥集里多一个密钥。
如果验证方配置的是静态密钥而不是 JWKS URL,就没有 C 可等;轮换需要在每个验证方上协调修改配置,这正是每个 OpenID Connect 库都默认使用 JWKS URL 的原因。
kid 的规则
- 唯一且永不重复使用。RFC 7517 §4.5 规定,同一密钥集中的密钥“应当(SHOULD)使用不同的‘kid’值”。跨时间复用比在同一密钥集内重复更糟:按
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。因此,含有两个密钥的 JWKS 加上不带kid的令牌,恰恰会在每次轮换期间出故障。 - 与
alg配对。在每个密钥上发布alg,让验证方把密钥绑定到唯一一种算法,这是 RFC 8725 §3.1 的要求。
验证库实际怎么做
上表中的 C 由验证方决定,而不是由你决定。对于手写的验证方,Microsoft 的签名密钥滚动更新指南是一套稳妥的默认做法:按 kid 单独缓存每个密钥 24 小时,每小时在后台刷新一次,遇到未知的 kid 时也刷新,但最多每 5 分钟一次。下表中各库的默认值,取自所列版本的源代码,或取自打印其常量的程序:
| 库 | 缓存时长 | 遇到未知 kid 时的刷新 | 强制刷新 |
|---|---|---|---|
jose 6.2.13 createRemoteJWKSet | 10 分钟 | 每 30 秒最多一次 | jwks.reload() |
| jwks-rsa 4.1.0 | 10 分钟,按 kid 缓存 | 每次未命中都刷新,除非设置 rateLimit: true(此时每分钟 10 次) | 创建新的客户端 |
PyJWT 2.15.1 PyJWKClient | 300 秒 | 每 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 用 JWKSourceBuilder 构建 NimbusJwtDecoder 的密钥源,但关闭了提前刷新和速率限制(JwkSetUriJwtDecoderBuilder 中的 .refreshAheadCache(false)、.rateLimited(false)),因此这些默认设置只剩下 5 分钟的缓存。
最后一行是个例外。go-oidc 会一直保留密钥集,直到有令牌用它验证失败为止;不存在按时间过期的机制。这对紧急移除意味着什么,请看下例:
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")
}
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 都不读取 JWKS 响应的 Cache-Control 头,它们的源代码中没有任何地方引用它。起作用的是验证方自己的设置,而你无法从颁发者一侧修改它。
JWKS 端点的 Cache-Control
Cache-Control 仍然重要:它会影响端点前面的 CDN 和反向代理,也会影响遵循提供商建议的验证方。例如,Google 让开发者“检查响应中的 Cache-Control 头,以确定何时应当重新获取它们”(Google Identity)。下面用这个脚本获取了 4 家大型颁发者和一个示例租户在2026年10月20日返回的内容:
#!/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
== 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 的端点列出了 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;而在响应状态码不是 200 时,jose 会报错 Expected 200 OK from the JSON Web Key Set HTTP response。
紧急移除
签名密钥泄露时,优先级就反过来了:目标不再是零失败请求,而是让伪造令牌被接受的时间尽可能短。顺序如下:
- 发布一个不含已泄露密钥、并且已包含新密钥的密钥集,同时立即把签名切换到新密钥。在部分验证方执行 kid 未命中刷新之前,它们会拒绝用新密钥签名的令牌;这是必须付出的代价。
- 强制验证方刷新。调用上表中的强制刷新方法,或者重启进程。对 go-oidc 来说,重启是唯一可靠的办法。如果你无法触达每一个验证方,它们的缓存时长就是你的暴露窗口:ASP.NET Core 默认设置下为 12 小时,go-oidc 则没有上限。
- 清除 JWKS 端点前面的所有缓存。仍在提供旧密钥集的 CDN 会让第 2 步前功尽弃。
- 把该密钥签名过的一切都视为可疑。撤销暴露期间签发的刷新令牌和会话,并在日志中查找带有已泄露
kid、却并非由你的颁发者签发的令牌。
要提前演练。紧急轮换就是去掉了等待环节的计划内轮换;如果计划内轮换已经自动化,并且每隔几周运行一次,那么紧急流程就是一份熟悉的脚本,而不是临场发挥。NIST SP 800-57 Part 1 Rev. 5 §5.3.6 建议签名私钥的最长密码周期(cryptoperiod)为“大约一到三年”;这是上限,而不是目标。
检查清单
- 密钥集中的每个密钥都有唯一的
kid、alg和"use": "sig";没有私钥成员,没有对称密钥。 - 令牌始终带有
kid。 - 新密钥至少提前 C 发布,然后才用于签名;旧密钥在最后一次签名之后至少保留 L + S + C。
- JWKS 端点发送经过斟酌、带有
stale-if-error的Cache-Control,绝不以 200 返回空密钥集,URL 也永不改变。 - 你运营的每个验证方都有已知的缓存时长,以及已知的强制刷新方式。
- 紧急流程已经成文,并且实际演练过。
参考资料
- OpenID Connect Core 1.0 §10.1.1:非对称签名密钥的轮换
- RFC 7517:JSON Web Key,§4.5(
kid)和 §5(JWK Set) - RFC 7638:JWK 指纹
- RFC 8725:JWT 最佳当前实践
- RFC 5861:
stale-while-revalidate和stale-if-error - Microsoft 标识平台:签名密钥滚动更新
- Google Identity:验证 Google ID 令牌
- NIST SP 800-57 Part 1 Rev. 5,§5.3.6