背景:为什么 JWT 密钥也要"轮换"
JWT(JSON Web Token) 是当代认证体系的常用方案, 无论是单体系统、微服务、还是前后端分离登录,几乎都会用到它。
但在大多数系统里,签名密钥往往是一成不变的—— 一旦生成,常年不换,代码里写死或放在配置文件中。
这其实非常危险:
- 一旦密钥被误传或泄露,攻击者就能伪造任意用户的合法 Token
- 无论是测试环境误配置,还是日志误打出 key,都可能导致密钥泄露,带来安全隐患
于是我们面临一个工程问题:
"如何能动态更新 JWT 签名密钥,且不让用户重新登录?"
目标:密钥可定期更新,但不影响登录状态
我们的目标是实现:
| 时间点 | 动作 | 用户状态 |
|---|---|---|
| 10月1日 | 使用 keypair_A 生成 JWT | 正常 |
| 10月10日 | 上线 keypair_B,新签发用它 | 老 Token 仍有效 |
| 10月20日 | 老 Token 全部过期 | 删除 keypair_A |
✅ 老 Token 正常可验签 ✅ 新 Token 自动使用新密钥 ✅ 用户无感知,不掉线
签名实现:HMAC vs RSA
JWT 支持多种签名算法,常见的有两种:
| 类型 | 算法示例 | 是否对称 | 特点 |
|---|---|---|---|
| HMAC(对称) | HS256 / HS512 | ✅ 是 | 签发方与验证方共用同一密钥 |
| RSA / ECDSA(非对称) | RS256 / ES256 | ❌ 否 | 签发方用私钥签名,验证方用公钥验签 |
很多系统为了图省事,默认使用 HMAC(例如 HS256)。 它确实简单,但存在一个致命问题:
一旦 HMAC 密钥泄露,攻击者可以伪造任何合法 Token。
这意味着:
签发方 = 验证方 = 攻击方(如果密钥泄露)
没有信任隔离
无法安全轮换:新旧密钥都得让验证逻辑同时持有
这也是为什么更高安全等级的系统都改用 RSA / ECDSA 非对称签名。
安全轮换的关键:KID(Key ID)+ 多版本密钥仓库
JWT Header 允许带一个 "kid" 字段,用来标识当前签名使用的密钥版本。 比如:
1{ 2 "alg": "RS256", 3 "typ": "JWT", 4 "kid": "key-20251013-956" 5} 6
这样,验证方只需要:
- 读取 header.kid
- 去 KeyStore 找对应公钥
- 使用它来验签
老 Token 用老公钥,新 Token 用新公钥,完美共存。
核心实现
技术架构
后端技术栈:
- Spring Boot 3 + Spring Scheduling
- JJWT 0.12.3(JWT 处理库)
- RSA 2048 非对称加密
- 内存 ConcurrentHashMap 存储(方便快速体验DEMO)
前端技术栈:
- HTML5 + CSS3 + JavaScript ES6
- Tailwind CSS UI 框架
- 前后端分离
核心组件设计
1️⃣ DynamicKeyStore - 动态密钥存储管理器
1@Service 2public class DynamicKeyStore { 3 // 线程安全的密钥存储 4 private final Map<String, KeyInfo> keyStore = new ConcurrentHashMap<>(); 5 private volatile String currentKeyId; 6 7 // 生成新密钥对 8 public String generateNewKeyPair() { 9 KeyPairGenerator generator = KeyPairGenerator.getInstance("RSA"); 10 generator.initialize(2048, new SecureRandom()); 11 KeyPair keyPair = generator.generateKeyPair(); 12 13 String keyId = "key-" + LocalDate.now() + "-" + timestamp; 14 KeyInfo keyInfo = new KeyInfo(keyId, keyPair); 15 16 // 轮换逻辑:旧密钥标记为非活跃,新密钥设为当前 17 if (currentKeyId != null) { 18 keyStore.get(currentKeyId).setActive(false); 19 } 20 currentKeyId = keyId; 21 keyStore.put(keyId, keyInfo); 22 23 return keyId; 24 } 25 26 // 根据KID获取密钥(支持多版本共存) 27 public KeyInfo getKey(String keyId) { 28 return keyStore.get(keyId); 29 } 30} 31
2️⃣ JwtTokenService - JWT 服务层
Token 生成(使用当前活跃密钥):
1public String generateToken(String username, Map<String, Object> claims) { 2 // 获取当前活跃密钥 3 var currentKey = keyStore.getCurrentKey(); 4 String keyId = currentKey.getKeyId(); 5 6 // 构建JWT,设置KID 7 JwtBuilder builder = Jwts.builder() 8 .subject(username) 9 .issuedAt(new Date()) 10 .expiration(Date.from(Instant.now().plus(24, ChronoUnit.HOURS))) 11 .header().keyId(keyId).and() 12 .signWith(currentKey.getKeyPair().getPrivate(), Jwts.SIG.RS256); 13 14 // 添加自定义声明 15 if (claims != null && !claims.isEmpty()) { 16 builder.claims().add(claims); 17 } 18 19 return builder.compact(); 20} 21
Token 验证(支持多版本密钥):
1public Claims validateToken(String token) throws JwtException { 2 // 1. 解析Header获取KID 3 String[] parts = token.split("\\."); 4 String headerJson = new String(Base64.getUrlDecoder().decode(parts[0])); 5 Map<String, Object> headerMap = mapper.readValue(headerJson, Map.class); 6 String keyId = (String) headerMap.get("kid"); 7 8 if (keyId == null) { 9 throw new JwtException("Token缺少密钥ID (kid)"); 10 } 11 12 // 2. 根据KID获取对应公钥 13 var keyInfo = keyStore.getKey(keyId); 14 if (keyInfo == null) { 15 throw new JwtException("找不到对应的密钥: " + keyId); 16 } 17 PublicKey publicKey = keyInfo.getKeyPair().getPublic(); 18 19 // 3. 使用公钥验证Token 20 Jws<Claims> jws = Jwts.parser() 21 .verifyWith(publicKey) 22 .build() 23 .parseSignedClaims(token); 24 25 return jws.getPayload(); 26} 27
3️⃣ KeyRotationScheduler - 定时轮换调度器
1@Component 2public class KeyRotationScheduler { 3 4 @Value("${jwt.rotation-period-days:7}") 5 private int rotationPeriodDays; 6 7 @Value("${jwt.grace-period-days:14}") 8 private int gracePeriodDays; 9 10 // 应用启动时初始化 11 @EventListener(ApplicationReadyEvent.class) 12 public void initialize() { 13 keyStore.initialize(); 14 } 15 16 // 定时轮换:每天凌晨2点检查 17 @Scheduled(cron = "0 0 2 * * ?") 18 public void scheduledKeyRotation() { 19 var currentKey = keyStore.getCurrentKey(); 20 long daysSinceCreation = ChronoUnit.DAYS.between( 21 currentKey.getCreatedAt(), LocalDateTime.now() 22 ); 23 24 if (daysSinceCreation >= rotationPeriodDays) { 25 String newKeyId = keyStore.generateNewKeyPair(); 26 logger.info("密钥轮换完成: {} -> {}", currentKeyId, newKeyId); 27 } 28 } 29 30 // 定时清理:每天凌晨3点清理过期密钥 31 @Scheduled(cron = "0 0 3 * * ?") 32 public void scheduledKeyCleanup() { 33 List<String> removedKeys = keyStore.cleanupExpiredKeys(gracePeriodDays); 34 if (!removedKeys.isEmpty()) { 35 logger.info("清理了 {} 个过期密钥", removedKeys.size()); 36 } 37 } 38} 39
4️⃣ API接口
认证相关:
POST /api/auth/login- 用户登录POST /api/auth/validate- Token验证POST /api/auth/refresh- Token刷新GET /api/auth/me- 获取当前用户信息
管理功能:
POST /api/auth/admin/rotate-keys- 手动轮换密钥POST /api/auth/admin/cleanup-keys- 清理过期密钥
演示功能:
GET /api/demo/key-stats- 获取密钥统计POST /api/demo/parse-token- 解析TokenPOST /api/demo/generate-test-token- 生成测试TokenGET /api/demo/protected- 受保护资源
5️⃣ 前端交互界面
DEMO提供了完整的前后端分离演示界面
用户登录:登录认证和状态显示
受保护资源:演示Token保护机制
密钥信息:实时密钥存储状态监控
Token解析:JWT结构分析工具
管理功能:手动密钥轮换和清理
平滑过渡策略
密钥轮换不是"替换",而是"共存"。
| 阶段 | 动作 | 状态 |
|---|---|---|
| ① 新密钥上线 | 新 Token 用新 Key 签发 | 双密钥并行 |
| ② 老 Token 仍验证通过 | 旧 Key 在验证端保留 | 用户无感 |
| ③ 老 Token 过期 | 删除旧 Key | 安全收尾 |
整个过程无须人工干预,也不需要让用户重新登录。
关键验证点
✅ 新Token使用新密钥:轮换后新生成的Token包含新的KID
✅ 旧Token仍可验证:轮换前的Token继续正常使用
✅ 用户无感知:整个轮换过程对用户完全透明
✅ 系统监控:实时查看密钥状态和轮换历史
总结
在实际项目中,密钥管理往往是被忽视的角落。直到安全审计时才发现问题。通过合理运用JWT的KID字段和RSA的非对称特性,我们可以让系统自动处理密钥轮换,而不是事后补救。
从代码量来看,增加密钥轮换功能并不需要大幅改动现有架构,但带来的安全收益是长期的。
《SpringBoot实现JWT动态密钥轮换》 是转载文章,点击查看原文。

