适用场景
业务使用 RS256 JWT 在网关、API 服务和后台任务之间传递身份。为了满足密钥泄露应急、合规轮换或证书到期要求,需要定期替换签名私钥,但又不能让尚未过期的旧令牌突然失效。
本文给出一套可直接落地的轮换方法:令牌头携带 kid,验证端同时信任新旧公钥,签发端再切换当前私钥,最后根据令牌最长生命周期安全下线旧公钥。示例使用 Python 3.11+、PyJWT 和 RS256。
现象描述
一次看似简单的密钥替换,常出现以下故障:
- 发布后大量接口立即返回 401,但重新登录的用户正常;
- 部分实例能验证新令牌,部分实例提示
unknown kid; - 签发端已改用新私钥,验证端的公钥缓存还没刷新;
- 为兼容历史令牌永久保留旧公钥,泄露后的攻击窗口无法收敛;
- 验证代码相信 JWT 头里的
alg,产生算法混淆风险; - 回滚应用版本时,旧版本不认识新
kid,导致二次故障。
根因通常不是“新密钥错误”,而是把轮换当成一次配置覆盖。JWT 在有效期内是离线凭证,新旧令牌必然会并存,因此密钥变更必须设计成一个有重叠窗口的状态迁移。
轮换时序
设旧密钥为 key-2026-06,新密钥为 key-2026-09,令牌最长有效期为 30 分钟,允许时钟偏差为 60 秒。
安全顺序如下:
- 生成新密钥对,私钥只进入签发服务的密钥管理系统;
- 所有验证端先发布新公钥,同时继续保留旧公钥;
- 确认验证端都能识别新
kid后,签发端切换到新私钥; - 至少等待“最长令牌有效期 + 时钟偏差 + 配置传播余量”;
- 确认旧
kid的验证量归零,再删除旧公钥; - 删除前保留快速恢复旧公钥的回滚能力,但不要恢复旧私钥签发。
关键不变量是:任何时刻,验证端都必须先于签发端认识即将使用的 kid。
生成密钥
下面命令生成 3072 位 RSA 密钥。生产环境优先使用 KMS、HSM 或集中密钥管理服务;文件示例仅用于说明格式。
umask 077
openssl genpkey -algorithm RSA \
-pkeyopt rsa_keygen_bits:3072 \
-out jwt-key-2026-09-private.pem
openssl pkey \
-in jwt-key-2026-09-private.pem \
-pubout \
-out jwt-key-2026-09-public.pem
openssl pkey -in jwt-key-2026-09-private.pem -check -noout
umask 077 避免新文件被同机其他用户读取。验证服务只需要公钥,绝不能把私钥复制到每个业务服务。密钥文件、文件内容和令牌原文也不应写入日志。
安装示例依赖:
python -m pip install "PyJWT[crypto]>=2.8,<3"
可直接使用的签发与验证代码
创建 jwt_keyring.py:
from __future__ import annotations
import re
from dataclasses import dataclass
from datetime import UTC, datetime, timedelta
from pathlib import Path
from typing import Final
import jwt
ISSUER: Final = "https://auth.example.com"
AUDIENCE: Final = "blog-api"
ALGORITHM: Final = "RS256"
KID_PATTERN: Final = re.compile(r"^[A-Za-z0-9._-]{1,64}$")
class TokenRejected(ValueError):
"""表示令牌不能被安全接受。"""
@dataclass(frozen=True)
class KeyRing:
"""保存当前签名密钥以及允许验证的公钥集合。"""
active_kid: str
private_key: str
public_keys: dict[str, str]
@classmethod
def from_files(
cls,
*,
active_kid: str,
private_key_path: Path,
public_key_paths: dict[str, Path],
) -> "KeyRing":
"""从受控文件加载密钥,并在启动阶段校验配置。"""
if not KID_PATTERN.fullmatch(active_kid):
raise ValueError("当前 kid 格式不合法")
if active_kid not in public_key_paths:
raise ValueError("当前签名密钥缺少对应公钥")
public_keys = {
kid: path.read_text(encoding="utf-8")
for kid, path in public_key_paths.items()
if KID_PATTERN.fullmatch(kid)
}
if len(public_keys) != len(public_key_paths):
raise ValueError("公钥集合包含格式不合法的 kid")
return cls(
active_kid=active_kid,
private_key=private_key_path.read_text(encoding="utf-8"),
public_keys=public_keys,
)
def issue(self, subject: str, *, lifetime: timedelta) -> str:
"""签发带有明确 kid、受众和过期时间的访问令牌。"""
if not subject or len(subject) > 128:
raise ValueError("subject 长度不合法")
if not timedelta(minutes=1) <= lifetime <= timedelta(minutes=30):
raise ValueError("令牌有效期必须在 1 到 30 分钟之间")
now = datetime.now(UTC)
claims = {
"sub": subject,
"iss": ISSUER,
"aud": AUDIENCE,
"iat": now,
"nbf": now,
"exp": now + lifetime,
}
return jwt.encode(
claims,
self.private_key,
algorithm=ALGORITHM,
headers={"kid": self.active_kid},
)
def verify(self, token: str) -> dict[str, object]:
"""按 kid 选择公钥,并固定算法完成完整验证。"""
if len(token) > 8192:
raise TokenRejected("令牌长度超限")
try:
header = jwt.get_unverified_header(token)
except jwt.PyJWTError as exc:
raise TokenRejected("令牌头无法解析") from exc
kid = header.get("kid")
if not isinstance(kid, str) or not KID_PATTERN.fullmatch(kid):
raise TokenRejected("令牌缺少合法 kid")
public_key = self.public_keys.get(kid)
if public_key is None:
raise TokenRejected("令牌 kid 未受信任")
try:
return jwt.decode(
token,
public_key,
algorithms=[ALGORITHM],
audience=AUDIENCE,
issuer=ISSUER,
leeway=60,
options={"require": ["sub", "iss", "aud", "iat", "nbf", "exp"]},
)
except jwt.PyJWTError as exc:
raise TokenRejected("令牌签名或声明校验失败") from exc
验证时读取未校验的头部,只用于查找候选公钥;它本身不可信。真正的安全边界在后续 jwt.decode():算法被服务端固定为 RS256,同时校验签名、签发方、受众、有效期和生效时间。不要把头部的 alg 直接传给 algorithms。
双钥配置示例
轮换第一阶段,验证端配置新旧两个公钥,但签发端仍使用旧私钥:
from pathlib import Path
from jwt_keyring import KeyRing
key_ring = KeyRing.from_files(
active_kid="key-2026-06",
private_key_path=Path("/run/secrets/jwt-key-2026-06-private.pem"),
public_key_paths={
"key-2026-06": Path("/etc/app/keys/jwt-key-2026-06-public.pem"),
"key-2026-09": Path("/etc/app/keys/jwt-key-2026-09-public.pem"),
},
)
确认所有验证端就绪后,只修改签发端的 active_kid 和私钥路径。公钥集合保持双钥状态:
key_ring = KeyRing.from_files(
active_kid="key-2026-09",
private_key_path=Path("/run/secrets/jwt-key-2026-09-private.pem"),
public_key_paths={
"key-2026-06": Path("/etc/app/keys/jwt-key-2026-06-public.pem"),
"key-2026-09": Path("/etc/app/keys/jwt-key-2026-09-public.pem"),
},
)
配置应以一次原子版本发布,不要先覆盖文件再逐项刷新进程。服务启动时加载并校验完整 KeyRing,失败就拒绝接收流量,避免处于“能启动但无法验签”的半配置状态。
轮换验收测试
至少验证以下四条路径:
from datetime import timedelta
import pytest
from jwt_keyring import KeyRing, TokenRejected
def test_rotation(old_ring: KeyRing, new_ring: KeyRing) -> None:
"""验证新旧令牌在重叠窗口内都可用。"""
old_token = old_ring.issue("user-1001", lifetime=timedelta(minutes=30))
new_token = new_ring.issue("user-1001", lifetime=timedelta(minutes=30))
assert new_ring.verify(old_token)["sub"] == "user-1001"
assert new_ring.verify(new_token)["sub"] == "user-1001"
def test_unknown_kid_is_rejected(new_ring: KeyRing, forged_token: str) -> None:
"""未知 kid 必须失败,不能回退为任意公钥遍历。"""
with pytest.raises(TokenRejected, match="kid 未受信任"):
new_ring.verify(forged_token)
完整流水线还应覆盖:过期令牌失败、错误 aud/iss 失败、被篡改载荷失败、缺失 kid 失败,以及下线旧公钥后旧令牌失败。若使用 JWKS,还应模拟缓存未刷新、刷新失败和未知 kid 请求风暴。
监控与定位
验证失败日志只记录必要的低敏上下文:
消息:JWT 验证失败
reason=unknown_kid kid=key-2026-09 service=order-api config_version=184
建议按 reason 统计 401:unknown_kid、expired、invalid_signature、invalid_issuer、invalid_audience。kid 可以记录,但不要记录完整 JWT、私钥、公钥正文或用户敏感声明。
切换签发前设置发布门禁:所有实例上报的 config_version 达到目标版本,且通过一个使用新私钥签发的合成令牌验证。切换后重点观察新旧 kid 的成功验证量和各类失败率。若 unknown_kid 上升,优先暂停签发切换或回滚签发端,不要删除旧公钥。
回滚与应急泄露
普通配置发布失败时,回滚策略是让签发端暂时恢复使用旧私钥,同时验证端继续保留双公钥。这样新旧令牌仍然有效,不需要强制所有用户重新登录。
如果轮换原因是旧私钥已经泄露,则不能继续用旧私钥签发。是否立即移除旧公钥取决于风险等级:立即移除能阻断攻击者继续伪造,但会让合法旧令牌失效;保留完整重叠窗口则维持可用性,却延长攻击窗口。高风险泄露通常应立即撤销旧 kid,配合会话版本、令牌黑名单或强制重新认证,并向用户明确说明影响。
预防措施
- 为每把密钥分配不可复用的
kid,不要用default、current等会重复指向不同材料的名称; - 私钥只授予签发服务,验证服务只持有公钥;
- 把最大令牌 TTL 固定在服务端,避免调用方签发超长令牌拖延旧钥下线;
- 对密钥配置做版本化、原子发布和启动校验;
- 定期演练“预发布公钥、切换签发、等待、退役旧钥”的完整流程;
- 对未知
kid的刷新请求限频,防止攻击者制造密钥源请求风暴; - 固定允许的算法、
issuer和audience,不要只验证签名; - 把密钥创建、启用、停用和删除纳入审计,但日志中不出现密钥内容。
总结
JWT 密钥轮换的核心不是替换一份 PEM 文件,而是管理离线令牌与多版本密钥的并存期。正确顺序是“验证端先接受新公钥,签发端再启用新私钥,等待旧令牌自然过期,最后退役旧公钥”。配合明确的 kid、固定算法、完整声明校验、发布门禁和按失败原因监控,才能把密钥轮换从一次高风险变更变成可验证、可观测、可回滚的常规操作。
Discussion
评论