背景:为什么 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"
字段,用来标识当前签名使用的密钥版本。 比如:
json
{
"alg": "RS256",
"typ": "JWT",
"kid": "key-20251013-956"
}
这样,验证方只需要:
- 读取 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 - 动态密钥存储管理器
java
@Service
public class DynamicKeyStore {
// 线程安全的密钥存储
private final Map<String, KeyInfo> keyStore = new ConcurrentHashMap<>();
private volatile String currentKeyId;
// 生成新密钥对
public String generateNewKeyPair() {
KeyPairGenerator generator = KeyPairGenerator.getInstance("RSA");
generator.initialize(2048, new SecureRandom());
KeyPair keyPair = generator.generateKeyPair();
String keyId = "key-" + LocalDate.now() + "-" + timestamp;
KeyInfo keyInfo = new KeyInfo(keyId, keyPair);
// 轮换逻辑:旧密钥标记为非活跃,新密钥设为当前
if (currentKeyId != null) {
keyStore.get(currentKeyId).setActive(false);
}
currentKeyId = keyId;
keyStore.put(keyId, keyInfo);
return keyId;
}
// 根据KID获取密钥(支持多版本共存)
public KeyInfo getKey(String keyId) {
return keyStore.get(keyId);
}
}
2️⃣ JwtTokenService - JWT 服务层
Token 生成(使用当前活跃密钥):
java
public String generateToken(String username, Map<String, Object> claims) {
// 获取当前活跃密钥
var currentKey = keyStore.getCurrentKey();
String keyId = currentKey.getKeyId();
// 构建JWT,设置KID
JwtBuilder builder = Jwts.builder()
.subject(username)
.issuedAt(new Date())
.expiration(Date.from(Instant.now().plus(24, ChronoUnit.HOURS)))
.header().keyId(keyId).and()
.signWith(currentKey.getKeyPair().getPrivate(), Jwts.SIG.RS256);
// 添加自定义声明
if (claims != null && !claims.isEmpty()) {
builder.claims().add(claims);
}
return builder.compact();
}
Token 验证(支持多版本密钥):
java
public Claims validateToken(String token) throws JwtException {
// 1. 解析Header获取KID
String[] parts = token.split("\\.");
String headerJson = new String(Base64.getUrlDecoder().decode(parts[0]));
Map<String, Object> headerMap = mapper.readValue(headerJson, Map.class);
String keyId = (String) headerMap.get("kid");
if (keyId == null) {
throw new JwtException("Token缺少密钥ID (kid)");
}
// 2. 根据KID获取对应公钥
var keyInfo = keyStore.getKey(keyId);
if (keyInfo == null) {
throw new JwtException("找不到对应的密钥: " + keyId);
}
PublicKey publicKey = keyInfo.getKeyPair().getPublic();
// 3. 使用公钥验证Token
Jws<Claims> jws = Jwts.parser()
.verifyWith(publicKey)
.build()
.parseSignedClaims(token);
return jws.getPayload();
}
3️⃣ KeyRotationScheduler - 定时轮换调度器
java
@Component
public class KeyRotationScheduler {
@Value("${jwt.rotation-period-days:7}")
private int rotationPeriodDays;
@Value("${jwt.grace-period-days:14}")
private int gracePeriodDays;
// 应用启动时初始化
@EventListener(ApplicationReadyEvent.class)
public void initialize() {
keyStore.initialize();
}
// 定时轮换:每天凌晨2点检查
@Scheduled(cron = "0 0 2 * * ?")
public void scheduledKeyRotation() {
var currentKey = keyStore.getCurrentKey();
long daysSinceCreation = ChronoUnit.DAYS.between(
currentKey.getCreatedAt(), LocalDateTime.now()
);
if (daysSinceCreation >= rotationPeriodDays) {
String newKeyId = keyStore.generateNewKeyPair();
logger.info("密钥轮换完成: {} -> {}", currentKeyId, newKeyId);
}
}
// 定时清理:每天凌晨3点清理过期密钥
@Scheduled(cron = "0 0 3 * * ?")
public void scheduledKeyCleanup() {
List<String> removedKeys = keyStore.cleanupExpiredKeys(gracePeriodDays);
if (!removedKeys.isEmpty()) {
logger.info("清理了 {} 个过期密钥", removedKeys.size());
}
}
}
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的非对称特性,我们可以让系统自动处理密钥轮换,而不是事后补救。
从代码量来看,增加密钥轮换功能并不需要大幅改动现有架构,但带来的安全收益是长期的。