4. 密钥管理工程实践

JWT 的安全性并不取决于签名算法本身有多复杂,而取决于签名密钥是否被正确生成、隔离存储、定期轮换、可控吊销,以及验证方能否通过 JWKS 端点准确拿到当前有效的公钥。本节围绕这条密钥生命周期主线,把工程上真正会踩坑的环节逐一展开。

一、密钥生成:算法与密钥长度先定死

签名密钥的生成必须与算法绑定。当前工程实践中,RS256(RSASSA-PKCS1-v1_5 + SHA-256)和 ES256(ECDSA P-256 + SHA-256)是主流选择。RS256 兼容性最好,密钥长度建议不低于 2048 位;ES256 签名更短、性能更好,但部分老旧客户端库支持不完整。HS256 属于对称密钥,签发方与验证方共享同一密钥,一旦验证方被攻破即等于签发密钥泄露,因此只适合单体应用或完全可信的内部服务,不应作为跨域、多租户场景的默认方案。

生成时应使用密码学安全随机源,而不是普通伪随机数。以 OpenSSL 为例,生成一把 2048 位 RSA 私钥并导出公钥:

# 生成 RSA 私钥(PKCS#8,便于跨语言加载)
openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out jwt_rsa_priv.pem

# 导出对应公钥
openssl rsa -in jwt_rsa_priv.pem -pubout -out jwt_rsa_pub.pem

# 生成 ES256 私钥(P-256 曲线)
openssl ecparam -name prime256v1 -genkey -noout -out jwt_ec_priv.pem

生成后应立即为密钥分配一个全局唯一的 kid,并记录算法、用途、创建时间、计划轮换时间等元数据。私钥文件权限应限制为仅服务账号可读(如 0600),且绝不能进入代码仓库、镜像层或日志。

二、存储:从环境变量到 KMS/HSM 的演进

把私钥放在环境变量或配置文件里,是很多项目的起点,但它只解决了“不硬编码”,并没有解决“谁能读、读了能否审计、泄露后能否快速失效”。更稳妥的做法是让私钥永不离开受控的密钥管理系统:应用只持有对密钥的引用,签名操作通过 KMS 的 Sign API 完成。

KMS(Key Management Service)提供密钥的集中托管、访问控制、审计日志和自动轮换能力;HSM(硬件安全模块)则进一步把密钥封存在专用硬件中,私钥在物理上不可导出,签名在硬件内完成,适合金融、政务等高合规要求场景。两者的典型分工是:KMS 负责密钥生命周期管理与策略,HSM 作为其底层密钥材料保护层。集成时应用侧通常只需把本地签名调用替换为一次远程签名调用,例如 AWS KMS 的 Sign 接口,指定 KeyId 与 SigningAlgorithm=RSASSA_PKCS1_V1_5_SHA_256,返回的签名再拼装进 JWT。这样即使应用主机被入侵,攻击者也无法直接导出私钥。

三、轮换与吊销:让旧密钥体面退场

轮换的核心矛盾是:新令牌要用新密钥签,但旧令牌在过期前仍需被验证。因此轮换不是“换掉”,而是“并存 + 逐步淘汰”。推荐流程是:

  1. 生成新密钥对,分配新 kid,先只发布公钥到 JWKS,暂不用于签发;
  2. 观察一段时间,确认所有验证方都能拉到新公钥;
  3. 切换签发方使用新私钥签名,JWT 头部 kid 指向新密钥;
  4. 旧公钥继续保留在 JWKS 中,直到“旧密钥最后一次签发时间 + 令牌最大有效期 + 时钟偏移容忍”全部过去;
  5. 确认无旧令牌后,从 JWKS 移除旧公钥并销毁旧私钥。

轮换周期没有统一标准,常见做法是 90 天或更短,高敏感场景可缩短到 30 天甚至按次轮换。吊销则用于密钥疑似泄露等紧急情况:立即从 JWKS 移除对应公钥,使新验证请求直接失败;同时应维护一个已吊销 kid 列表,防止缓存中的旧 JWKS 继续放行。需要注意的是,吊销只影响“验证”,已经签发的令牌在过期前仍可能被已缓存公钥的验证方接受,因此吊销必须配合较短的令牌有效期和主动的 JWKS 缓存失效策略。

JWT 密钥生命周期与 JWKS 发布链路 ① 生成 ② 存储/托管 ③ 签发 ④ 轮换 ⑤ 吊销 RSA-2048 / ES256 CSPRNG 随机源 分配唯一 kid 记录元数据 算法/用途/时间 KMS / HSM 私钥不可导出 访问控制 + 审计 Sign API 远程签名 应用仅持 KeyId JWT 签发 Header.kid = 当前 alg = RS256 exp 控制有效期 签名由 KMS 完成 轮换(并存期) 新公钥先发布 再切换签发 旧公钥保留至 旧令牌全部过期 紧急吊销 JWKS 移除公钥 维护吊销 kid 列表 缓存主动失效 配合短 exp JWKS 端点(/.well-known/jwks.json) 验证方按 kid 查找公钥;缓存需设置 max-age,并在轮换/吊销时主动失效 kid = "k-2024-01"(当前签发) kid = "k-2023-10"(并存验证) kid = "k-2023-07"(待淘汰) kid = "k-2023-04"(已吊销) 验证方(资源服务) 解析 Header.kid → 拉取 JWKS → 验签 按 kid 取公钥 kid 是签发方与验证方之间的唯一契约:轮换与吊销都通过 JWKS 中 kid 的增删来体现

四、JWKS 端点设计

JWKS 是一个 JSON 文档,包含一组 JWK(JSON Web Key),每个 JWK 至少要有 kty、use、alg、kid 以及公钥参数。一个典型的 RS256 公钥条目如下:

{
  "keys": [
    {
      "kty": "RSA",
      "use": "sig",
      "alg": "RS256",
      "kid": "k-2024-01",
      "n": "0vx7agoebGcQ...(Base64URL 编码的模数)",
      "e": "AQAB"
    }
  ]
}

端点设计有几个工程要点。第一,路径应稳定且可发现,通常放在 /.well-known/jwks.json,并允许跨域访问(验证方可能来自不同域)。第二,响应必须设置合理的 Cache-Control,例如 max-age=300,既减少验证方频繁拉取,又保证轮换和吊销能在可接受时间内生效。第三,JWKS 只发布公钥,绝不能包含私钥参数(如 RSA 的 d、p、q)。第四,应支持按 kid 快速定位,验证方拿到 JWT 后先读 Header 中的 kid,再在 JWKS 中匹配,匹配不到就应拒绝,而不是回退到“用第一个密钥试”。

五、kid 治理:唯一、稳定、可追溯

kid 是签发方与验证方之间唯一的契约标识,治理不好会直接导致验签失败或误用旧密钥。建议遵循以下规则:

  • 全局唯一:同一 JWKS 内不得出现重复 kid,跨环境(开发/测试/生产)也应避免复用,防止配置串用。
  • 不可变:kid 一旦分配给某把密钥就不再更改,轮换时生成新 kid,而不是复用旧值。
  • 可读可追溯:推荐使用“用途-日期-序号”形式,如 sig-2024-01-01-01,便于在日志和审计中定位是哪把密钥签的。
  • 与算法绑定:kid 对应的 JWK 中 alg 必须与 JWT Header 的 alg 一致,验证方应校验二者匹配,防止算法混淆攻击。
  • 吊销可查:维护已吊销 kid 集合,验证方在 JWKS 中找不到 kid 时直接拒绝,并记录告警,便于发现仍在用旧密钥签发的异常来源。

常见误区:把 kid 当作安全边界。攻击者可以伪造任意 kid,真正的安全来自“用对应公钥验签成功”。因此验证方必须严格按 kid 查找公钥,找不到就拒绝,绝不能因为找不到而放宽校验或跳过验签。

落地检查清单:① 私钥是否只存在于 KMS/HSM 且不可导出;② 是否每把密钥都有唯一且不可变的 kid;③ JWKS 是否只含公钥并设置了合理缓存;④ 轮换是否遵循“先发布公钥、再切换签发、后淘汰旧钥”;⑤ 吊销后是否有主动缓存失效与告警;⑥ 验证方是否严格按 kid 匹配并校验 alg 一致性。

把生成、存储、轮换、吊销、JWKS 发布和 kid 治理串成一条闭环,JWT 的信任根才真正可控。工程上最容易被忽视的不是算法选择,而是轮换期间的并存窗口和验证方对 kid 的严格匹配——这两点做扎实,绝大多数密钥相关的线上故障都可以提前避免。