后端

如何在不登出所有用户的情况下轮换 JWT 签名密钥

简单地轮换 JWT 签名密钥会使所有活跃的令牌失效,并登出所有用户。以下是可避免此问题的重叠窗口和 kid 设计。

本文由 AI 模型从英文原文翻译而来,措辞可能与原文有出入。 阅读英文原文

JWT 之所以可信,是因为它由仅服务器持有的密钥签名。如果该密钥泄露,任何人都可以伪造有效的令牌,因此你必须按计划更换密钥。最简单直接的方法,即用新密钥替换旧密钥,会导致所有仍在传输中的令牌失效,并且每个已登录的用户在下次发出请求时都会被强制登出。本文展示了一种轮换密钥而无需强制用户登出的设计:同时保留多个有效密钥,用最新的密钥进行签名,并用所有这些密钥进行验证,直到旧令牌自行过期。

其核心思想很简单。签名和验证不必使用同一个密钥。你只用一个密钥(即当前密钥)进行签名,但用一个密钥集进行验证。只要令牌是由该密钥集中的任何一个密钥签名的,它就保持有效。这样,密钥轮换就变成了向密钥集中添加一个新密钥,将签名操作切换到该密钥,并且只在所有用旧密钥签名的内容都已失效后,才移除旧密钥。

为什么一次简单的密钥交换会导致所有人登出

假设你签发的令牌有效期为 24 小时。在任何时刻,你都有一批动态变化的有效令牌,其中一些是几秒钟前签发的,另一些则是在将近 24 小时前签发的。所有这些令牌都是用签发时所用的当前密钥签名的。

现在你更换了密钥。下一个请求到来时,携带的是用旧密钥签名的令牌,你的服务器尝试用新密钥来验证它,签名不匹配,验证失败。用户看起来就像被登出了。这种情况会发生在交换密钥前签发的所有令牌上,而这正是你的全部活跃用户群体。你并非有意撤销任何人的令牌。你只是更改了那个能让他们的令牌得以验证的密钥,并且没有回头路。

令牌的生命周期是症结所在。在你交换密钥后,一个由旧密钥签名的令牌在其整个生命周期内都可以保持有效。因此,旧密钥必须在至少这么长的时间内保持可用于验证。这一个观察点就是整个设计的核心。

解决方案:用一个密钥签名,用多个密钥验证

将密钥拆分为两种角色。

  • 签名使用一个密钥,即当前密钥。每个新令牌都用它来签名。
  • 验证使用一个密钥集,即当前密钥加上其令牌可能仍处于有效期内的任何旧密钥。

当收到一个令牌时,你不能假定它是由当前密钥签名的。你需要找出是哪个密钥签名的,并用该密钥进行验证。如果签名密钥仍在你的密钥集中,则该令牌有效。如果它是由一个你已经停用的密钥签名的,验证就会失败,这正是你希望对一个确实已失效的密钥所达到的效果。

为了让第一步的成本低廉,每个密钥都会获得一个称为 kid(密钥 ID)的短标识符。签名时,你将 kid 戳记到令牌的头部。验证时,你从头部读取 kid 并查找该确切的密钥。没有 kid 意味着你必须尝试密钥集中的每个密钥,直到有一个能用为止,这会更慢,并且会泄露少量的时间信息。有了 kid,验证密钥的查找就只是一次 map 读取。

以下是带有 kid 的 JWT 头部示例。该头部只是经过 base64url 编码的 JSON,因此在进行任何签名检查之前都是可读的:

{
  "alg": "HS256",
  "kid": "2026-07-01"
}

每个密钥的 kid 可以是任何唯一且稳定的值。日期、随机字符串或计数器都可以。唯一的规则是它不能与另一个活动密钥冲突,并且不能包含秘密信息,因为任何人都可以读取它。

轮换时间线,分步详解

轮换是一系列状态的序列,而不是一次性的切换。每个状态都是可以安全停留的,这意味着你可以基于计时器自动完成转换,而无需协调同步重启。

阶段 签名密钥 验证集 正在发生什么
稳定 K1 {K1} 只存在一个密钥,所有令牌都由 K1 签名
引入 K1 {K1, K2} K2 已被添加到集合中,但尚未使用它进行签名
切换 K2 {K1, K2} 新令牌使用 K2,旧的 K1 令牌仍然可以验证
重叠 K2 {K1, K2} 等待最后一个 K1 令牌过期
淘汰 K2 {K2} K1 已被移除,其令牌反正也全都失效了

唯一重要的持续时间是重叠期。从停止使用 K1 签名的那一刻算起,K1 必须在验证集中保留至少与令牌最长生命周期相等的时间。如果令牌的有效期为 24 小时,而你在中午切换到 K2,那么最新可能生成的 K1 令牌是在中午前一刻铸造的,并将在第二天中午前一刻失效。如果在此之前移除 K1,你将会使那些合法签发的令牌失效。在该时间窗口之后移除它,则没有有效的令牌依赖于它,因此淘汰操作对用户而言是无感的。

请注意,验证集在任何时候都不会缩减到小于有效令牌所需的大小。这正是让轮换过程对用户不可见的原因。他们永远不会看到由轮换引起的签名失败,只会看到因令牌本已过期而导致的失败。

Go 的一个最小化实现

这是一个持有多个密钥的密钥环,它使用当前密钥进行签名,并通过 kid 进行验证。为简洁起见,它使用 HMAC (HS256),但同样的结构也适用于非对称密钥(RS256、ES256),在这种情况下,验证集持有公钥,而只有签名者持有私钥。

package auth

import (
	"errors"
	"time"

	"github.com/golang-jwt/jwt/v5"
)

type key struct {
	id       string
	secret   []byte
	notAfter time.Time // stop using for verification after this
}

type Keyring struct {
	current string          // kid of the signing key
	keys    map[string]*key // kid -> key, the verification set
}

func (r *Keyring) Sign(claims jwt.MapClaims) (string, error) {
	k := r.keys[r.current]
	tok := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
	tok.Header["kid"] = k.id
	return tok.SignedString(k.secret)
}

func (r *Keyring) Verify(raw string) (jwt.MapClaims, error) {
	claims := jwt.MapClaims{}
	_, err := jwt.ParseWithClaims(raw, claims, func(t *jwt.Token) (any, error) {
		kid, ok := t.Header["kid"].(string)
		if !ok {
			return nil, errors.New("token has no kid")
		}
		k, ok := r.keys[kid]
		if !ok {
			return nil, errors.New("unknown or retired kid")
		}
		if time.Now().After(k.notAfter) {
			return nil, errors.New("key past its verification window")
		}
		return k.secret, nil
	}, jwt.WithValidMethods([]string{"HS256"}))
	return claims, err
}

有两个细节至关重要。jwt.WithValidMethods 选项固定了可接受的算法,这可以防范经典的 alg 混淆攻击,即调用者将 alg 设置为 none 或将 RS256 降级为 HS256。并且,kid 查找在遇到未知或已停用的密钥时会返回错误,而不是回退到默认值,因此由你特意移除的密钥签名的令牌会验证失败。

轮换本身是对相同结构的一个小改动。一个计划任务会添加下一个密钥,切换签名者,并修剪掉任何超出其窗口期的内容:

func (r *Keyring) Rotate(newKid string, secret []byte, tokenTTL time.Duration) {
	// New key can verify tokens minted from now until now + tokenTTL,
	// plus a margin so the last-minted token is safely covered.
	r.keys[newKid] = &key{
		id:       newKid,
		secret:   secret,
		notAfter: time.Now().Add(tokenTTL * 2),
	}
	r.current = newKid // sign with the new key from here on

	// Drop keys whose verification window has passed.
	for kid, k := range r.keys {
		if time.Now().After(k.notAfter) {
			delete(r.keys, kid)
		}
	}
}

在多个实例间自动化轮换

单个进程在内存中持有一个密钥环很简单。实际部署时,负载均衡器后面有多个服务器实例,它们都必须就哪些密钥是活动的达成一致。如果实例 A 轮换出一个新密钥而实例 B 从未听说过它,那么由 A 签名的令牌将在 B 上验证失败,你又会遇到随机退出的问题。

解决方法是将密钥环保存在共享存储中,而不是每个进程的内存里。Redis 或数据库中的一行记录都可以。一个计划任务(通常是每周一次)生成一个新密钥,将其 kid 和其窗口期一同写入共享存储,并移动当前指针。每个实例都从存储中读取密钥集,可以通过带有 TTL 的短时缓存,也可以通过发布/订阅通知,这样在几秒钟内每个实例都能看到相同的密钥集。

一个实用的设置是始终保持两个密钥处于活动状态:当前密钥和下一个密钥,它们的窗口期相互重叠。每周运行的任务会将“下一个”密钥提升为“当前”密钥,并铸造一个新的“下一个”密钥。由于窗口期的重叠时间超过了令牌的生命周期,因此绝不会出现实例拒绝有效令牌的间隙。如果你已经运行了 Redis 来处理会话或速率限制,这只会增加一个小的键和一个类似 cron 的作业,不会有更重的负担。

对于非对称密钥,有一个成熟的标准来公开验证集:JWKS,这是一个位于已知 URL 的 JSON 文档,按 kid 列出你的公钥。资源服务器获取它、缓存它,并将传入令牌中的 kid 与公钥进行匹配。你令牌头中的 kid 与索引 JWKS 条目的 kid 是同一个,这就是为什么即使在单服务设置中也值得添加该标识符的原因。它是一条接缝,让其他服务可以在不持有你签名密钥的情况下验证你的令牌。

你需要接受的权衡

此设计实现了零停机轮换,但它并非没有代价。

你现在需要管理一组密钥,而不再是单个密钥:生成它们、安全地存储它们、在实例间同步它们,并按时修剪它们。修剪逻辑中的一个错误,要么会保留已失效的密钥(问题不大),要么会过早地移除某个密钥(导致登出事件)。共享存储成为你身份验证关键路径的一部分,因此其可用性现在会影响登录。

重叠窗口既是一种便利,也是一种安全成本。在重叠期间,旧密钥仍然被接受,所以如果你轮换密钥的原因是怀疑密钥泄漏,那么泄漏的密钥在该窗口的持续时间内仍然有效。仅凭此机制,你无法同时保证现有令牌有效并立即废止一个已泄露的密钥。缩小风险敞口的手段是缩短令牌的生命周期:访问令牌的生命周期为 15 分钟到一小时,并从一个生命周期更长的刷新令牌来刷新,这意味着在你切换密钥后,一个已泄露的签名密钥对攻击者来说,其有效时间最多只有那段很短的窗口期。这就是为什么短生命周期的访问令牌和刷新令牌要与密钥轮换一起使用的主要原因。

这一切的底层还存在无状态与有状态的选择。使用密钥集验证 JWT 是无状态的:任何实例仅凭内存中的密钥就可以检查任何令牌,无需为每个请求查询数据库,这正是 JWT 的全部吸引力所在。一旦你需要在特定令牌过期前撤销它(例如用户登出或账户被盗),你就需要状态:一个在每次请求时都要检查的令牌 ID 拒绝列表。这重新引入了你之前试图避免的每次请求的查询操作。许多系统接受一个小的拒绝列表来处理罕见的强制撤销情况,同时保持通用路径的无状态性。密钥轮换和单令牌撤销解决的是不同的问题,并且轮换并不能实现撤销功能。

当单个密钥就足够时

对于任何超出原型阶段的东西,轮换都是值得做的,但对于小型或短生命周期的系统来说,上述机制可能有些小题大做。在以下所有条件都成立的情况下,使用不带 kid 且没有密钥环的单个密钥是可以的:

  • 签名密钥仅存在于服务器内存或秘密管理器中,并且绝不通过不安全的通道传输,因此泄漏的可能性很小。
  • 令牌的生命周期很短,并且有刷新路径,因此,即使是强制轮换导致所有人都退出登录,也只是短暂的烦扰,而非服务中断。
  • 你控制着每一个验证方,因此没有外部方需要一个稳定的 kid 或 JWKS 端点来在变更期间保持工作。
  • 你可以容忍一个计划内的维护窗口来更换密钥,如果真到了那一步的话。

对于一个只有少数用户的内部工具,在非高峰时段更换密钥并接受重新登录的成本,远低于构建和运维一个密钥环。关键在于要清楚自己处于哪种情况。一旦你有了真实用户、外部验证方,或者有按固定时间表进行轮换的合规性要求,多密钥设计在你第一次进行无感轮换时就物有所值了。

该机制可以归结为一条规则。用最新的密钥签名,用所有可能仍有有效令牌的密钥进行验证,并且只在一个密钥可能签发的最长生命周期的令牌过期后,才停用该密钥。只要正确设置重叠窗口期,轮换就不再是用户能感知到的事件了。它变成了一项在用户保持登录状态时运行的任务。