《Spring Boot 实战》9.2 JWT 无状态认证

对比无状态与 Session 在水平扩展、注销与吊销上的取舍,给出 HS256 与 RS256 的选型判据,用 JwtDecoder/JwtEncoder 落地签发与校验、token 过期与刷新轮换、密钥轮换与 kid,并列出算法混淆、none 算法等安全错误。

本节目标:判断你的服务到底该不该上无状态 JWT,选对签名算法,用 JwtDecoder/JwtEncoder 落地签发与校验,并正确处理刷新、轮换与 kid,避开算法混淆等经典漏洞。
适用版本:Spring Boot 4.1.x(Java 21)

9.2 JWT 无状态认证

上一节把认证模型搭好了,但用的还是 httpBasic——每次请求都要带用户名密码,且服务端要查库比对。这一节把「图书借阅管理服务」换成 JWT:客户端登录一次拿到 token,之后带着 token 访问,服务端不查会话、不查库即可确认身份。

先给结论:无状态 JWT 不是银弹,它用「服务端无法主动失效」换来了「服务端无需共享会话」。是否值得,取决于你的部署形态和对注销时效的要求。本节先讲清这笔交易,再讲怎么把技术细节做对。

9.2.1 无状态与 Session 的取舍

维度Session(服务端会话)无状态 JWT
水平扩展需要粘性会话或共享存储(Redis)无状态,任意实例都能独立校验
注销删会话即可立即生效服务端无法主动作废,只能等过期
吊销单个凭证精确到会话需额外引入黑名单/版本号,等于又变成有状态
每次请求开销查一次会话存储一次签名校验(CPU,无 IO)
载荷大小只有 session id随 claim 数量增长,每次请求都带
跨服务/跨域难共享天然适合,一个 token 服务多个 API

选型判据:

  • 单体应用、用户量不大、需要「强制下线」:Session + Redis 更简单,别硬上 JWT。
  • 多实例无共享存储、或要跨多个服务:JWT 的收益才成立。
  • 强注销需求(如后台封禁要秒级生效):即便用 JWT,也得配一个短过期 + 服务端吊销名单,别指望纯无状态。

「图书借阅管理服务」属于第二类:它要在多个实例上水平扩展,还要给馆员端、读者小程序、后台管理三个前端共用一个认证中心,所以选 JWT。

9.2.2 JWT 结构与签名算法选择

JWT 是三段 base64url 用 . 连接:header.payload.signature。

  • header:{"alg":"RS256","kid":"loan-2026-09","typ":"JWT"}。
  • payload:claims,标准字段有 iss(签发者)、sub(主体)、aud(受众)、exp(过期)、iat(签发时间)、jti(唯一 id),外加自定义的 roles、tenant。
  • signature:对前两段的签名。

payload 是 base64url 编码,不是加密。任何人拿到 token 都能解出内容,所以绝对不要把密码、身份证号、内部密钥放进去。

算法选择:

算法类型适用场景代价
HS256对称(同一密钥签发与校验)只有一个服务签发、也只有它校验密钥必须分发给所有校验方,泄露即全盘沦陷
RS256非对称(私钥签、公钥验)多服务校验、需要对外公开 JWKS签名/校验稍慢,密钥管理更复杂

判据很简单:只要校验方不止一个进程,或者校验方不是你自己,就用 RS256。这样校验方只拿公钥,拿不到签发能力。「图书借阅管理服务」有多个下游服务要校验 token,因此选 RS256。

9.2.3 配置 JwtDecoder 与 JwtEncoder

JWT 支持来自 spring-boot-starter-security-oauth2-resource-server(4.x 的新名)。校验侧用 JwtDecoder,签发侧用 JwtEncoder。

RS256 下两者都由同一份 JWKSource 派生:公钥给 decoder,私钥留在 encoder。

package com.example.loan.security;

import com.nimbusds.jose.jwk.JWKSet;
import com.nimbusds.jose.jwk.RSAKey;
import com.nimbusds.jose.jwk.source.ImmutableJWKSet;
import com.nimbusds.jose.jwk.source.JWKSource;
import com.nimbusds.jose.proc.SecurityContext;
import java.security.KeyPair;
import java.security.KeyPairGenerator;
import java.security.interfaces.RSAPrivateKey;
import java.security.interfaces.RSAPublicKey;
import java.util.UUID;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.oauth2.jwt.JwtDecoder;
import org.springframework.security.oauth2.jwt.JwtEncoder;
import org.springframework.security.oauth2.jwt.NimbusJwtDecoder;
import org.springframework.security.oauth2.jwt.NimbusJwtEncoder;

@Configuration
public class JwtConfig {

    @Bean
    RSAKey signingKey() throws Exception {
        KeyPairGenerator generator = KeyPairGenerator.getInstance("RSA");
        generator.initialize(2048);
        KeyPair pair = generator.generateKeyPair();
        return new RSAKey.Builder((RSAPublicKey) pair.getPublic())
            .privateKey((RSAPrivateKey) pair.getPrivate())
            .keyID("loan-" + UUID.randomUUID())   // kid 用于密钥轮换,见 9.2.6
            .build();
    }

    @Bean
    JWKSource<SecurityContext> jwkSource(RSAKey signingKey) {
        return new ImmutableJWKSet<>(new JWKSet(signingKey));
    }

    @Bean
    JwtEncoder jwtEncoder(JWKSource<SecurityContext> jwkSource) {
        return new NimbusJwtEncoder(jwkSource);
    }

    @Bean
    JwtDecoder jwtDecoder(RSAKey signingKey) throws Exception {
        return NimbusJwtDecoder
            .withPublicKey(signingKey.toRSAPublicKey())
            .signatureAlgorithm(org.springframework.security.oauth2.jose.jws.SignatureAlgorithm.RS256)
            .build();
    }
}

这段是「教学版」:每次启动生成一对新密钥,重启即失效,仅用于本地。生产必须换成从配置/密钥管理服务加载的固定密钥,否则一重启所有 token 全部失效,且多实例之间的公钥还不一致。

NimbusJwtDecoder 有几种构建方式,用途不同:

构建方式适用场景
withPublicKey(pub)自己签发自己校验,密钥本地持有
withJwkSetUri(url)校验方从认证中心的 JWKS 端点拉公钥,支持自动刷新与轮换
withSecretKey(secret)HS256 对称密钥

多服务架构下推荐 withJwkSetUri:认证中心暴露 /.well-known/jwks.json,各资源服务只配置 URL,密钥轮换时无需重新部署。

9.2.4 签发与校验

签发一个 access token:

package com.example.loan.security;

import java.time.Duration;
import java.time.Instant;
import java.util.List;
import org.springframework.security.oauth2.jose.jws.SignatureAlgorithm;
import org.springframework.security.oauth2.jwt.JwsHeader;
import org.springframework.security.oauth2.jwt.JwtClaimsSet;
import org.springframework.security.oauth2.jwt.JwtEncoder;
import org.springframework.security.oauth2.jwt.JwtEncoderParameters;
import org.springframework.stereotype.Service;

@Service
public class TokenIssuer {

    private static final Duration ACCESS_TTL = Duration.ofMinutes(15);
    private final JwtEncoder encoder;

    public TokenIssuer(JwtEncoder encoder) {
        this.encoder = encoder;
    }

    public String issue(Member member) {
        Instant now = Instant.now();
        JwtClaimsSet claims = JwtClaimsSet.builder()
            .issuer("https://auth.loan.example")
            .subject(member.getId().toString())
            .audience(List.of("book-loan-api"))
            .issuedAt(now)
            .expiresAt(now.plus(ACCESS_TTL))
            .id(java.util.UUID.randomUUID().toString())      // jti,用于吊销名单
            .claim("roles", List.of(member.getRole().name()))
            .claim("tenant", member.getTenantId())
            .build();
        JwsHeader headers = JwsHeader.with(SignatureAlgorithm.RS256)
            .keyId(currentKid())
            .build();
        return encoder.encode(JwtEncoderParameters.from(headers, claims)).getTokenValue();
    }

    private String currentKid() {
        return "loan-2026-09";
    }
}

校验侧接进上一节的过滤器链,用 oauth2ResourceServer:

http.oauth2ResourceServer(oauth2 -> oauth2
    .jwt(jwt -> jwt
        .decoder(jwtDecoder)
        .jwtAuthenticationConverter(tenantAwareConverter())));

JwtDecoder 默认会校验签名、exp、nbf。如果还要校验 iss 和 aud,需要显式加 validator,否则「别的系统签发的、恰好用同一密钥的 token」也会被接受:

import org.springframework.security.oauth2.core.DelegatingOAuth2TokenValidator;
import org.springframework.security.oauth2.core.OAuth2TokenValidator;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.security.oauth2.jwt.JwtIssuerValidator;
import org.springframework.security.oauth2.jwt.JwtTimestampValidator;
import org.springframework.security.oauth2.jwt.JwtValidators;

OAuth2TokenValidator<Jwt> validator = new DelegatingOAuth2TokenValidator<>(
    JwtValidators.createDefaultWithIssuer("https://auth.loan.example"),
    new JwtTimestampValidator(Duration.ofSeconds(30)));   // 容忍 30s 时钟偏移
((NimbusJwtDecoder) decoder).setJwtValidator(validator);

JwtValidators.createDefaultWithIssuer(...) 已经包含时间校验;再叠一个 JwtTimestampValidator 只是为了放开时钟偏移容差。生产里多实例时钟不可能完全一致,不给容差就会随机出现「刚签发的 token 被判过期」。

jwtAuthenticationConverter 负责把 JWT 里的 roles claim 转成 GrantedAuthority,并把 tenant 放进认证主体的属性里——这一步是多租户的基础,9.3 会用到。

9.2.5 过期与刷新

access token 的过期时间是一笔权衡:太长则泄露后危害窗口大,太短则客户端频繁刷新。实践取值:

token 类型典型有效期存放位置是否入库
access token5–30 分钟客户端内存否(无状态)
refresh token7–30 天客户端安全存储是(哈希后存库)

refresh token 不能也做成无状态 JWT,否则就失去了「可吊销」的意义。正确做法是不透明随机串 + 服务端存储 + 用一次换一次(轮换):

  • 登录返回 access + refresh。
  • 刷新时校验 refresh 是否有效且未被使用,签发新 access,同时作废旧 refresh、下发新 refresh。
  • 如果某个已被使用的 refresh 再次出现,说明它被窃取了,此时应吊销该用户整条 refresh 链。
@Service
public class RefreshService {

    private final RefreshTokenRepository tokens;   // 存的是哈希,不是明文

    public TokenPair refresh(String rawRefresh) {
        String hash = sha256(rawRefresh);
        RefreshToken stored = tokens.findByHash(hash)
            .orElseThrow(() -> new InvalidRefreshTokenException());
        if (stored.isUsed()) {
            tokens.revokeAllForUser(stored.getMemberId());   // 检测到重用,整链吊销
            throw new TokenReuseDetectedException();
        }
        stored.markUsed();
        tokens.save(stored);
        return issueNewPair(stored.getMemberId());
    }
}

刷新接口本身要限流,并且只在 HTTPS 下使用——refresh token 一旦被中间人截获,攻击者能长期冒充用户。

9.2.6 密钥轮换与 kid

密钥不能永久不变:员工离职、疑似泄露、合规要求都会触发轮换。轮换的难点不是换,而是换了之后老 token 不能立刻失效。

方案是 JWKS + kid:

  1. 密钥有唯一标识 kid,写在 token header 里。
  2. 认证中心发布 JWKS 端点,同时公布「当前签名密钥」和「仍在有效期内、仅供校验的旧密钥」。
  3. 校验方按 token header 里的 kid 选择对应公钥,因此新旧 token 都能验。
  4. 等所有旧 token 都过期后,再从 JWKS 里移除旧公钥。

轮换流程:

第 0 天   生成新密钥 new,kid=loan-2026-09
          JWKS 同时公布 new(可签可验)与 old(仅验)
第 0 天   签发开始使用 new;old 仍能验证存量 token
第 30 天  old 签发的 token 全部过期(access 15 分钟,refresh 30 天)
          JWKS 移除 old

注意 refresh token 的有效期决定了下线旧密钥的最早时间——只要还有效期内的 refresh token 可能换取新 access,就不能把旧密钥彻底删掉。

9.2.7 常见安全错误

  • 算法混淆:不要信任 header 里的 alg。某些实现会「按 token 声称的算法去校验」,攻击者把 RS256 改成 HS256 并用公钥当 HMAC 密钥,就能伪造 token。Spring 的 NimbusJwtDecoder 在构建时就固定了算法(如上面的 signatureAlgorithm(RS256)),不会读 header 的 alg,务必保持这一约束。
  • 接受 none 算法:alg: none 表示无签名。合规的 decoder 必须拒绝它,自己实现校验逻辑时最容易漏。
  • 把敏感信息放 payload:payload 只是 base64url,不是加密。密码、证件号、密钥都不能放。
  • 忽略 exp:手写校验时只看签名不看时间。用框架的 JwtValidators.createDefaultWithIssuer(...) 可避免。
  • 不校验 aud / iss:导致为 A 系统签发的 token 能访问 B 系统。
  • refresh token 明文入库:库被读即等于凭证泄露,必须存哈希。

小结

无状态 JWT 用「服务端无法主动失效」换来「无需共享会话」,选型要看部署形态和吊销时效要求。签名算法上,多校验方一律 RS256。落地时用 JwtDecoder/JwtEncoder,校验必须叠加 iss/aud 与时钟容差;refresh token 要做成不透明串、入库哈希、用一次换一次;密钥轮换靠 JWKS + kid,旧密钥下线时间受 refresh 有效期约束。

阅读导航:上一节:9.1 Spring Security 配置模型 · 下一节:9.3 方法级授权与多租户 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「java」更多文章

  1. 《Spring Boot 入门》18.3 打包与运行
  2. 《Spring Boot 入门》18.2 实现
  3. 《Spring Boot 入门》18.1 需求与设计