Spring Boot 3.2 + jjwt 0.12 的 JWT 签名密钥如何做到零停机轮换?
该问题的正文、回答与评论。
【背景】
我维护一个 Spring Boot 3.2 + Spring Security 6.3 的后端,鉴权走 JWT,签发与校验都用 jjwt 0.12.x,签名算法 HS256。当前实现是把一个 32 字节随机密钥塞在 application.yml 里(通过启动参数注入),所有实例共享同一把密钥,签发和校验都是本地完成,没有中心化的 auth 服务。
【现象 / 卡点】 公司安全红线要求"密钥 90 天强制轮换 + 员工离职立即轮换",但现实是:
- 直接换
application.yml里的密钥,需要滚动重启所有实例,老 access_token 立刻全部失效,线上几千在线用户被踢; - 想改成"新旧两把密钥并存一段时间",但 jjwt 0.12 的
Jwts.builder()一次只接受一个SecretKey,校验侧Jwts.parser().verifyWith(key)也只收一把——没有原生的 multi-key 入口; - 想引入
kid头让客户端按 kid 选 key,但发现 jjwt 0.12 的Jwts.parser()默认不会按kid路由(Jwts.parser().verifyWith()是单密钥校验),需要自己写JwsHeaderValidator或在 Filter 里做分发,文档里没给现成模板; - 不确定"过渡窗口期"该开多久、校验端要不要做黑名单、
kid命名规范怎么定(时间戳?UUID?版本号?)。
【已尝试的排查与结果】
- 查了 jjwt 0.12 官方 README 和
JwtsAPI,确认 0.12 没有内置的 key ring / multi-key 支持(0.11 的Keys.secretKeyFor()也只是生成单把); - 试了把密钥从 yml 挪到 Vault / 云 KMS,但 KMS 只解决"密钥从哪来",不解决"轮换瞬间新旧并存"的校验路径;
- 试了自签 CA + RS256 +
kid头(Jwts.builder().header("kid", ...)),签发侧 OK,校验侧需要自己写kid → PublicKey的映射表,没有现成 Spring Boot Starter 可直接用; - 搜过 NIST SP 800-63B 关于密钥轮换的建议,只给了原则("old key must remain valid for a grace period"),没给具体工程模板。
【期望得到的答案形态】 希望有同学能给出可复现的工程方案,最好包含:
- 推荐的密钥形态(HS256 双密钥 vs RS256 + kid + 自签 CA,各自的适用场景与代价);
- 具体的代码骨架:签发端如何带
kid、校验端如何按kid分发到不同的SecretKey/PublicKey(最好有JwtDecoder或OncePerRequestFilter的可运行片段); - 轮换 SOP:旧密钥保留多久、何时下线、怎么灰度、出错了怎么回滚;
- 踩坑清单:jjwt 0.12 校验
alg与kid的已知坑、Spring Security 6.x 里JwtDecoder单例 vs 多实例的处理方式、alg=none之类的降级攻击防护。
我这边可以先把我现在的 Filter 骨架贴出来,方便大家给具体改法。谢谢!
结论先行:你现在的核心问题不是「jjwt 0.12 没有 multi-key」,而是「把密钥当作一个不可分割的整体」。一旦把它拆成「kid 路由 + 双 key 并存 + 校验端按 kid 分发」三块,jjwt 0.12 完全够用,HS256 双密钥就够,不必上 RS256 + 自签 CA——除非你们以后要跨服务共享公钥(那时再升级更划算)。下面给可落地的骨架 + SOP + 踩坑清单。
1) 形态选择:HS256 双密钥 vs RS256 + kid
| 维度 | HS256 双密钥 | RS256 + kid + 自签 CA | |---|---|---| | 签发/校验端是否同一进程 | 是 | 否(公钥可分发) | | 实现复杂度 | 低(一把 32B key × 2) | 中(keypair + kid 路由 + CA 管理) | | 员工离职立即轮换 | 双密钥都换,影响面大 | 只换私钥,公钥可不动 | | 跨服务共享 | 需下发密钥(风险大) | 只需下发公钥 | | 你们现状(单服务、本地签发校验) | ✅ 推荐 | 过度设计 |
判据:如果「所有校验方都是你自己这套 Spring Boot 实例」,HS256 双密钥足够;只有当「校验方会扩散到网关/其他团队」时,才值得上 RS256。
2) 代码骨架(jjwt 0.12 + Spring Security 6.3)
签发端:
@Component
public class JwtIssuer {
// keyId -> SecretKey,至少保留 2 把(current + previous)
private final Map<String, SecretKey> keys;
private final AtomicReference<String> currentKid;
public JwtIssuer(KeyRingConfig cfg) {
this.keys = cfg.map(); // e.g. {"2026-09-01": keyA, "2026-12-01": keyB}
this.currentKid = new AtomicReference<>(cfg.currentKid());
}
public String issue(Subject s, Duration ttl) {
String kid = currentKid.get();
return Jwts.builder()
.header().add("kid", kid).and()
.subject(s.id())
.issuedAt(new Date())
.expiration(new Date(System.currentTimeMillis() + ttl.toMillis()))
.signWith(keys.get(kid), Jwts.SIG.HS256)
.compact();
}
}
校验端(关键:按 kid 分发到对应 key,alg 白名单强制):
@Component
public class MultiKeyJwtDecoder implements JwtDecoder {
private final Map<String, SecretKey> keys;
public MultiKeyJwtDecoder(KeyRingConfig cfg) { this.keys = cfg.map(); }
@Override
public Jwt decode(String token) throws JwtException {
JwsHeader header = Jwts.parser().unsecuredHeader().setSigningKey(dummy).parseClaimsJwt(token).getHeader();
// 上面仅为拿 kid 的简化写法;推荐用 parser().unsecuredHeader() 先解 header
String kid = (String) header.get("kid");
if (kid == null || !keys.containsKey(kid)) {
throw new MalformedJwtException("unknown or missing kid: " + kid);
}
SecretKey key = keys.get(kid);
return Jwts.parser()
.verifyWith(key)
.requireValidSignature()
.clockOffset(Duration.ofSeconds(30))
.build()
.parseSignedClaims(token)
.getPayload();
}
}
关键防御点:
alg白名单:requireValidSignature()已隐含拒绝alg=none,但要再显式断言header.getAlgorithm() == HS256,防降级;kid缺失/未注册直接拒:不要 fallback 到 currentKid(这是很多实现的坑——旧 token 没 kid 就放行,等于给降级攻击留后门);- 时钟偏移:
clockOffset(30s)别给太大,500s 起步就是事故。
3) 轮换 SOP(90 天 / 员工离职通用)
T0 生成新密钥 keyB,keys 表加 "2026-12-01": keyB,currentKid 暂不动
└─ 校验端已能验证 kid=2026-12-01 的 token(keyB 已注册)
└─ 此时所有新 token 仍用 keyA 签发,零影响
T1 灰度切签发:currentKid = "2026-12-01"
└─ 新 token 全部走 keyB
└─ 校验端同时接受 keyA / keyB → 老 token 全量存活
T2 观察窗口 = max(access_token TTL, 24h)
└─ 假设 access_token TTL = 2h,窗口开 24h 足够
└─ 监控:kid=2026-09-01 的 token 占比,应从 100% 线性降到 0%
T3 窗口结束,确认 kid=2026-09-01 占比 = 0(或 <0.01%)
└─ 从 keys 表移除 keyA,currentKid 保持 2026-12-01
└─ 此后 kid=2026-09-01 的 token 直接 401(符合预期,因为它们早已过期)
员工离职立即轮换:跳过 T2 观察窗口,直接 T0 → T1 → T3(把 TTL 内未过期的老 token 视为「待踢」,可接受;若想秒踢,加 jti 黑名单,TTL 多长就缓存多久)。
4) 踩坑清单
- jjwt 0.12 没有
Jwts.parser().verifyWith(kid):必须自己按 kid 路由,JwtDecoder是唯一干净的注入点(Spring Security 6.3 的JwtDecoder是函数式接口,可多实例并存)。 - Spring Security 6.x 的
JwtDecoder是单例 Bean:你的MultiKeyJwtDecoder实现JwtDecoder接口即可,SecurityFilterChain里直接jwtDecoder(...)注入,不需要自定义 Filter。 alg=none降级:jjwt 0.12 的parseSignedClaims已拒绝,但别用parseClaimsJwt(后者接受未签名 token),校验端统一走parseSignedClaims。kid命名:推荐YYYY-MM-DD(可读 + 单调递增 + 一眼看出新旧),别用 UUID(排查时不知道哪个是哪个);也别用「v1/v2」(v10 之后尴尬)。- 密钥存放:
application.yml里只放 keyId,密钥本体走 Vault / KMS / 启动参数注入;keysmap 在KeyRingConfig里一次性构建后只读,不要做运行时动态加载(除非你上了 Vault agent,那另说)。
一句话总结
「双密钥 + kid 路由 + 校验端按 kid 分发 + alg 白名单 + kid 缺失即拒」这五件事做到位,HS256 就能撑住 90 天轮换 + 离职秒换,jjwt 0.12 完全够用;上 RS256 的时机是「校验方扩散到你自己控制之外的进程」。