SpringBoot實現JWT動態(tài)密鑰輪換的示例詳解
背景:為什么 JWT 密鑰也要"輪換"
JWT(JSON Web Token) 是當代認證體系的常用方案, 無論是單體系統(tǒng)、微服務、還是前后端分離登錄,幾乎都會用到它。
但在大多數系統(tǒng)里,簽名密鑰往往是一成不變的—— 一旦生成,常年不換,代碼里寫死或放在配置文件中。
這其實非常危險:
- 一旦密鑰被誤傳或泄露,攻擊者就能偽造任意用戶的合法 Token
- 無論是測試環(huán)境誤配置,還是日志誤打出 key,都可能導致密鑰泄露,帶來安全隱患
于是我們面臨一個工程問題:
"如何能動態(tài)更新 JWT 簽名密鑰,且不讓用戶重新登錄?"
目標:密鑰可定期更新,但不影響登錄狀態(tài)
我們的目標是實現:
| 時間點 | 動作 | 用戶狀態(tài) |
|---|---|---|
| 10月1日 | 使用 keypair_A 生成 JWT | 正常 |
| 10月10日 | 上線 keypair_B,新簽發(fā)用它 | 老 Token 仍有效 |
| 10月20日 | 老 Token 全部過期 | 刪除 keypair_A |
- 老 Token 正??沈灪?/li>
- 新 Token 自動使用新密鑰
- 用戶無感知,不掉線
簽名實現:HMAC vs RSA
JWT 支持多種簽名算法,常見的有兩種:
| 類型 | 算法示例 | 是否對稱 | 特點 |
|---|---|---|---|
| HMAC(對稱) | HS256 / HS512 | ? 是 | 簽發(fā)方與驗證方共用同一密鑰 |
| RSA / ECDSA(非對稱) | RS256 / ES256 | ? 否 | 簽發(fā)方用私鑰簽名,驗證方用公鑰驗簽 |
很多系統(tǒng)為了圖省事,默認使用 HMAC(例如 HS256)。 它確實簡單,但存在一個致命問題:
一旦 HMAC 密鑰泄露,攻擊者可以偽造任何合法 Token。
這意味著:
簽發(fā)方 = 驗證方 = 攻擊方(如果密鑰泄露)
沒有信任隔離
無法安全輪換:新舊密鑰都得讓驗證邏輯同時持有
這也是為什么更高安全等級的系統(tǒng)都改用 RSA / ECDSA 非對稱簽名。
安全輪換的關鍵:KID(Key ID)+ 多版本密鑰倉庫
JWT Header 允許帶一個 "kid" 字段,用來標識當前簽名使用的密鑰版本。 比如:
{
"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 - 動態(tài)密鑰存儲管理器
@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 生成(使用當前活躍密鑰):
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 驗證(支持多版本密鑰):
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 - 定時輪換調度器
@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- 獲取密鑰統(tǒng)計POST /api/demo/parse-token- 解析TokenPOST /api/demo/generate-test-token- 生成測試TokenGET /api/demo/protected- 受保護資源
5.前端交互界面
DEMO提供了完整的前后端分離演示界面
用戶登錄:登錄認證和狀態(tài)顯示
受保護資源:演示Token保護機制
密鑰信息:實時密鑰存儲狀態(tài)監(jiān)控
Token解析:JWT結構分析工具
管理功能:手動密鑰輪換和清理
平滑過渡策略
密鑰輪換不是"替換",而是"共存"。
| 階段 | 動作 | 狀態(tài) |
|---|---|---|
| ① 新密鑰上線 | 新 Token 用新 Key 簽發(fā) | 雙密鑰并行 |
| ② 老 Token 仍驗證通過 | 舊 Key 在驗證端保留 | 用戶無感 |
| ③ 老 Token 過期 | 刪除舊 Key | 安全收尾 |
整個過程無須人工干預,也不需要讓用戶重新登錄。
關鍵驗證點
- 新Token使用新密鑰:輪換后新生成的Token包含新的KID
- 舊Token仍可驗證:輪換前的Token繼續(xù)正常使用
- 用戶無感知:整個輪換過程對用戶完全透明
- 系統(tǒng)監(jiān)控:實時查看密鑰狀態(tài)和輪換歷史
總結
在實際項目中,密鑰管理往往是被忽視的角落。直到安全審計時才發(fā)現問題。通過合理運用JWT的KID字段和RSA的非對稱特性,我們可以讓系統(tǒng)自動處理密鑰輪換,而不是事后補救。
從代碼量來看,增加密鑰輪換功能并不需要大幅改動現有架構,但帶來的安全收益是長期的。
到此這篇關于SpringBoot實現JWT動態(tài)密鑰輪換的示例詳解的文章就介紹到這了,更多相關SpringBoot JWT動態(tài)密鑰輪換內容請搜索腳本之家以前的文章或繼續(xù)瀏覽下面的相關文章希望大家以后多多支持腳本之家!
相關文章
Mybatis plus Dynamic Datasource 動態(tài)數據源及使用
dynamic-datasource-spring-boot-starter是一個基于springboot的快速集成多數據源的啟動器,它跟mybatis-plus是一個生態(tài)圈里的,很容易集成mybatis-plus,本文介紹Mybatis plus Dynamic Datasource 動態(tài)數據源的相關知識,感興趣的朋友一起看看吧2025-09-09
淺談Java中Map和Set之間的關系(及Map.Entry)
這篇文章主要介紹了淺談Java中Map和Set之間的關系(及Map.Entry),具有很好的參考價值,希望對大家有所幫助。一起跟隨小編過來看看吧2020-09-09

