适用场景

业务使用 RS256 JWT 在网关、API 服务和后台任务之间传递身份。为了满足密钥泄露应急、合规轮换或证书到期要求,需要定期替换签名私钥,但又不能让尚未过期的旧令牌突然失效。

本文给出一套可直接落地的轮换方法:令牌头携带 kid,验证端同时信任新旧公钥,签发端再切换当前私钥,最后根据令牌最长生命周期安全下线旧公钥。示例使用 Python 3.11+、PyJWT 和 RS256。

现象描述

一次看似简单的密钥替换,常出现以下故障:

  • 发布后大量接口立即返回 401,但重新登录的用户正常;
  • 部分实例能验证新令牌,部分实例提示 unknown kid
  • 签发端已改用新私钥,验证端的公钥缓存还没刷新;
  • 为兼容历史令牌永久保留旧公钥,泄露后的攻击窗口无法收敛;
  • 验证代码相信 JWT 头里的 alg,产生算法混淆风险;
  • 回滚应用版本时,旧版本不认识新 kid,导致二次故障。

根因通常不是“新密钥错误”,而是把轮换当成一次配置覆盖。JWT 在有效期内是离线凭证,新旧令牌必然会并存,因此密钥变更必须设计成一个有重叠窗口的状态迁移。

轮换时序

设旧密钥为 key-2026-06,新密钥为 key-2026-09,令牌最长有效期为 30 分钟,允许时钟偏差为 60 秒。

安全顺序如下:

  1. 生成新密钥对,私钥只进入签发服务的密钥管理系统;
  2. 所有验证端先发布新公钥,同时继续保留旧公钥;
  3. 确认验证端都能识别新 kid 后,签发端切换到新私钥;
  4. 至少等待“最长令牌有效期 + 时钟偏差 + 配置传播余量”;
  5. 确认旧 kid 的验证量归零,再删除旧公钥;
  6. 删除前保留快速恢复旧公钥的回滚能力,但不要恢复旧私钥签发。

关键不变量是:任何时刻,验证端都必须先于签发端认识即将使用的 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_kidexpiredinvalid_signatureinvalid_issuerinvalid_audiencekid 可以记录,但不要记录完整 JWT、私钥、公钥正文或用户敏感声明。

切换签发前设置发布门禁:所有实例上报的 config_version 达到目标版本,且通过一个使用新私钥签发的合成令牌验证。切换后重点观察新旧 kid 的成功验证量和各类失败率。若 unknown_kid 上升,优先暂停签发切换或回滚签发端,不要删除旧公钥。

回滚与应急泄露

普通配置发布失败时,回滚策略是让签发端暂时恢复使用旧私钥,同时验证端继续保留双公钥。这样新旧令牌仍然有效,不需要强制所有用户重新登录。

如果轮换原因是旧私钥已经泄露,则不能继续用旧私钥签发。是否立即移除旧公钥取决于风险等级:立即移除能阻断攻击者继续伪造,但会让合法旧令牌失效;保留完整重叠窗口则维持可用性,却延长攻击窗口。高风险泄露通常应立即撤销旧 kid,配合会话版本、令牌黑名单或强制重新认证,并向用户明确说明影响。

预防措施

  • 为每把密钥分配不可复用的 kid,不要用 defaultcurrent 等会重复指向不同材料的名称;
  • 私钥只授予签发服务,验证服务只持有公钥;
  • 把最大令牌 TTL 固定在服务端,避免调用方签发超长令牌拖延旧钥下线;
  • 对密钥配置做版本化、原子发布和启动校验;
  • 定期演练“预发布公钥、切换签发、等待、退役旧钥”的完整流程;
  • 对未知 kid 的刷新请求限频,防止攻击者制造密钥源请求风暴;
  • 固定允许的算法、issueraudience,不要只验证签名;
  • 把密钥创建、启用、停用和删除纳入审计,但日志中不出现密钥内容。

总结

JWT 密钥轮换的核心不是替换一份 PEM 文件,而是管理离线令牌与多版本密钥的并存期。正确顺序是“验证端先接受新公钥,签发端再启用新私钥,等待旧令牌自然过期,最后退役旧公钥”。配合明确的 kid、固定算法、完整声明校验、发布门禁和按失败原因监控,才能把密钥轮换从一次高风险变更变成可验证、可观测、可回滚的常规操作。