SpringBoot实现JWT动态密钥轮换

背景:为什么 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"
}

这样,验证方只需要:

  1. 读取 header.kid
  2. 去 KeyStore 找对应公钥
  3. 使用它来验签

老 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 - 解析Token
  • POST /api/demo/generate-test-token - 生成测试Token
  • GET /api/demo/protected - 受保护资源

5️⃣ 前端交互界面

DEMO提供了完整的前后端分离演示界面

用户登录:登录认证和状态显示

受保护资源:演示Token保护机制

密钥信息:实时密钥存储状态监控

Token解析:JWT结构分析工具

管理功能:手动密钥轮换和清理

平滑过渡策略

密钥轮换不是"替换",而是"共存"。

阶段 动作 状态
① 新密钥上线 新 Token 用新 Key 签发 双密钥并行
② 老 Token 仍验证通过 旧 Key 在验证端保留 用户无感
③ 老 Token 过期 删除旧 Key 安全收尾

整个过程无须人工干预,也不需要让用户重新登录。

关键验证点

新Token使用新密钥:轮换后新生成的Token包含新的KID

旧Token仍可验证:轮换前的Token继续正常使用

用户无感知:整个轮换过程对用户完全透明

系统监控:实时查看密钥状态和轮换历史

总结

在实际项目中,密钥管理往往是被忽视的角落。直到安全审计时才发现问题。通过合理运用JWT的KID字段和RSA的非对称特性,我们可以让系统自动处理密钥轮换,而不是事后补救。

从代码量来看,增加密钥轮换功能并不需要大幅改动现有架构,但带来的安全收益是长期的。

github.com/yuboon/java...

相关推荐
止语Lab23 分钟前
好的 DX 不等于少写代码——三种语言的摩擦力设计课
后端
吃饱了得干活23 分钟前
别再手动解析 LLM 输出了!LangChain 四种结构化输出方案对比
后端·python·langchain
程序员天天困28 分钟前
Arthas trace 命令怎么用?一行定位最慢那行代码
jvm·后端
Huiturn28 分钟前
GPT 5.6 连续编码 10 小时,纯 Python 啃下 Word 二进制格式——doc2docx 实现拆解
后端
用户2986985301435 分钟前
Python 实现 Excel 与 Markdown 互转的实用指南
后端·python·excel
用户772831049084037 分钟前
krono-job:零侵入、单二进制交付的分布式任务调度平台(开源)
后端
Conan在掘金44 分钟前
鸿蒙报错速查:Function lacks ending return statement,返回路径漏 return 就炸,根因 + 真解法
后端
人间凡尔赛1 小时前
AI-Native 云原生架构:2026 年从容器编排到智能体编排的范式革命
后端·云原生·架构
IT_陈寒1 小时前
Vue的响应式让我加班到凌晨3点,原来问题出在这
前端·人工智能·后端
卷无止境1 小时前
提升 Python 代码健壮性的方法大盘点
后端·python