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。这样即使应用主机被入侵,攻击者也无法直接导出私钥。
三、轮换与吊销:让旧密钥体面退场
轮换的核心矛盾是:新令牌要用新密钥签,但旧令牌在过期前仍需被验证。因此轮换不是“换掉”,而是“并存 + 逐步淘汰”。推荐流程是:
- 生成新密钥对,分配新
kid,先只发布公钥到 JWKS,暂不用于签发; - 观察一段时间,确认所有验证方都能拉到新公钥;
- 切换签发方使用新私钥签名,JWT 头部
kid指向新密钥; - 旧公钥继续保留在 JWKS 中,直到“旧密钥最后一次签发时间 + 令牌最大有效期 + 时钟偏移容忍”全部过去;
- 确认无旧令牌后,从 JWKS 移除旧公钥并销毁旧私钥。
轮换周期没有统一标准,常见做法是 90 天或更短,高敏感场景可缩短到 30 天甚至按次轮换。吊销则用于密钥疑似泄露等紧急情况:立即从 JWKS 移除对应公钥,使新验证请求直接失败;同时应维护一个已吊销 kid 列表,防止缓存中的旧 JWKS 继续放行。需要注意的是,吊销只影响“验证”,已经签发的令牌在过期前仍可能被已缓存公钥的验证方接受,因此吊销必须配合较短的令牌有效期和主动的 JWKS 缓存失效策略。
四、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 的严格匹配——这两点做扎实,绝大多数密钥相关的线上故障都可以提前避免。