在微服务与前后端分离成为主流的今天,Spring Security 几乎是 Java 后端做认证与权限控制的事实标准。传统 Session 方案在横向扩容和无状态 API 面前越来越吃力,而基于 JWT(JSON Web Token) 的鉴权链路能让每个请求自带身份,网关与业务服务各取所需。本文用一套可运行的示例,讲清 Spring Security 6 下 JWT 从签发、校验到角色授权的完整落地。
一、为什么要从 Session 切到 JWT
Session 方案依赖服务端保存会话状态:用户登录后容器在内存或 Redis 里挂一份会话,后续请求靠 Cookie 里的 JSESSIONID 找回。问题在三点:第一,多实例部署必须做 Session 共享,否则请求被轮询到另一台机器就掉登录;第二,浏览器同源策略让移动端、小程序、第三方服务接入很别扭;第三,无状态 API 每一次都要回查会话,增加了一次网络跳转。
JWT 把”你是谁、能干什么”编码进签名令牌,客户端每次请求在 Authorization 头带上它,服务端用密钥验签即可,不再查库。配合我们之前落地的 Spring Cloud Gateway 网关 做路由与限流,安全层只管鉴权,职责清晰。本篇默认你已经跑在 Spring Boot 3 与 Java 21 虚拟线程 之上,Spring Security 6 的 API 与 5.x 差异较大,注意不要照搬老教程。
二、依赖与基础配置
Spring Boot 3 场景下,引入 starter-security 与 jjwt(负责令牌编解码)。BOM 已管理版本,这里只写坐标:
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- JWT 编解码,0.12.x 起 API 变化较大 -->
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>0.12.6</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>0.12.6</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<version>0.12.6</version>
<scope>runtime</scope>
</dependency>
</dependencies>
jjwt 0.12 之后把 Key 构建、解析的 API 做了拆分,secretKey 必须用 Keys.hmacShaKeyFor 生成,且长度不低于 256 位,否则运行期直接抛异常。下面给出令牌工具类。如果你还在用 0.11 的老写法 Jwts.builder().setSubject(),升级后会发现 setSubject 已改为 subject,方法链命名整体向 Jakarta 规范看齐,老代码需要批量替换。
三、JWT 工具类:签发与校验
把令牌的创建、解析、过期判断收敛到一个 Provider 里,业务代码不直接碰 jjwt 细节。注意密钥从配置读取,生产环境务必放配置中心或 KMS,不要硬编码到源码。
@Component
public class JwtTokenProvider {
@Value("${app.jwt.secret}")
private String secret;
@Value("${app.jwt.expiration-ms:3600000}")
private long expirationMs;
private SecretKey key() {
// 密钥需 >= 256 bit,用 Base64 解码更稳妥
return Keys.hmacShaKeyFor(Decoders.BASE64.decode(secret));
}
public String generateToken(String username, List<String> roles) {
Date now = new Date();
Date exp = new Date(now.getTime() + expirationMs);
return Jwts.builder()
.subject(username)
.claim("roles", roles)
.issuedAt(now)
.expiration(exp)
.signWith(key())
.compact();
}
public String parseUsername(String token) {
return Jwts.parser()
.verifyWith(key())
.build()
.parseSignedClaims(token)
.getPayload()
.getSubject();
}
public boolean validate(String token) {
try {
Jwts.parser().verifyWith(key()).build()
.parseSignedClaims(token);
return true;
} catch (JwtException | IllegalArgumentException e) {
return false;
}
}
}
四、把 Token 织入请求链:OncePerRequestFilter
核心思路是写一个过滤器,在每次请求进入 DispatcherServlet 之前,从请求头取出 Bearer Token,验签通过后把用户信息塞进 SecurityContextHolder。这一步做好了,后续 Controller 里就能用 @PreAuthorize 拿到身份。
@Component
public class JwtAuthenticationFilter extends OncePerRequestFilter {
@Autowired
private JwtTokenProvider provider;
@Override
protected void doFilterInternal(HttpServletRequest req,
HttpServletResponse res,
FilterChain chain) throws IOException, ServletException {
String header = req.getHeader("Authorization");
if (header != null && header.startsWith("Bearer ")) {
String token = header.substring(7);
if (provider.validate(token)) {
String username = provider.parseUsername(token);
UsernamePasswordAuthenticationToken auth =
new UsernamePasswordAuthenticationToken(
username, null, List.of());
SecurityContextHolder.getContext().setAuthentication(auth);
}
}
chain.doFilter(req, res);
}
}
五、SecurityFilterChain:放行与鉴权
Spring Security 6 用 SecurityFilterChain Bean 取代废弃的 WebSecurityConfigurerAdapter。重点是三件事:放行登录与公开接口、把 JWT 过滤器排在用户名密码过滤器之前、把会话策略设为无状态。
@Configuration
@EnableMethodSecurity // 开启 @PreAuthorize 等注解
public class SecurityConfig {
@Autowired
private JwtAuthenticationFilter jwtFilter;
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http.csrf(AbstractHttpConfigurer::disable) // 无状态 API 通常关闭
.sessionManagement(s ->
s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(a -> a
.requestMatchers("/api/auth/login", "/public/**").permitAll()
.requestMatchers("/api/admin/**").hasRole("ADMIN")
.anyRequest().authenticated())
.addFilterBefore(jwtFilter,
UsernamePasswordAuthenticationFilter.class);
return http.build();
}
}
六、角色与权限:@PreAuthorize 精细化控制
登录接口校验账号密码后签发令牌,真正的权限判断下沉到方法级别。下面这张表覆盖了最常见的 SpEL 表达式,按需组合即可。
| 表达式 | 含义 | 典型场景 |
| hasRole(‘ADMIN’) | 拥有 ADMIN 角色 | 后台管理接口 |
| hasAuthority(‘user:read’) | 拥有具体权限点 | 按钮级数据权限 |
| authentication.name == #owner | 只能操作自己的资源 | 用户只能删自己的订单 |
| permitAll() / denyAll() | 全放行 / 全拒绝 | 健康检查、内网接口 |
登录与受保护接口的写法如下,注意登录接口本身要 permitAll,否则陷入”没令牌不能登录”的死循环:
@RestController
@RequestMapping("/api")
public class UserController {
@PostMapping("/auth/login")
public Map<String,String> login(@RequestBody LoginReq req) {
// 1. 用 UserDetailsService 校验账号密码
// 2. 校验通过再签发令牌
String token = provider.generateToken(req.username(), roles);
return Map.of("token", token);
}
@PreAuthorize("hasRole('ADMIN')")
@DeleteMapping("/admin/users/{id}")
public void deleteUser(@PathVariable Long id) {
// 只有 ADMIN 能进,非法请求在过滤器阶段即被 403
}
}
七、刷新令牌与登出
访问令牌设短一点(比如 1 小时)降低泄露风险,再发一个存活更久的刷新令牌。刷新接口同样 permitAll,用旧刷新令牌换新的访问令牌。JWT 本身无状态、无法主动作废,登出一般有两种做法:前端直接丢弃令牌;后端维护一个短期黑名单(Redis 存失效令牌的 jti,TTL 与过期时间对齐)。对于大多数内部系统,令牌够短 + 改密即失效已足够,不必过度设计。
八、常见坑与排查
| 现象 | 根因 | 修复 |
| 启动报密钥长度不足 | secret 小于 256 bit | 用 32 字节以上并 Base64 编码 |
| 永远 403 | @EnableMethodSecurity 漏加 | 补注解,确认角色带 ROLE_ 前缀 |
| 过滤器不生效 | addFilterBefore 顺序错 | 排在 UsernamePasswordAuthenticationFilter 前 |
| 跨域 OPTIONS 被拦 | 预检请求未放行 | 放行 OPTIONS 或配置 CorsConfigurationSource |
九、生产落地检查清单
| 检查项 | 建议 |
| 密钥管理 | 放配置中心 / KMS,禁止提交仓库 |
| 令牌有效期 | 访问令牌 ≤ 1h,刷新令牌 ≤ 7d |
| 传输安全 | 全站 HTTPS,配合 Nginx 反向代理 终止 TLS |
| 权限测试 | 在 GitHub Actions 里跑安全用例,覆盖越权场景 |
| 异常可观测 | 鉴权重试、限流接入 微服务治理 体系 |
十、JWT 与 OAuth2:什么时候该上授权框架
很多团队一上来就把 JWT 和 OAuth2 混为一谈。简单说,JWT 只是一种令牌格式,而 OAuth2 是一套授权协议,解决”第三方应用如何代表用户访问资源”。如果你只是自己的前后端之间做登录态,直接用本文的 JWT 方案就够;但当你需要”用微信/Google 账号登录””把接口开放给合作方””支持多种客户端委托授权”时,就该引入 OAuth2 授权服务器(如 Spring Authorization Server),把 JWT 当作它签发的令牌载体即可。
一个常见的反模式是”用 JWT 自己造一套 OAuth2″:前端存 refresh token 在 localStorage、后端手写授权码流程,最后既没拿到 OAuth2 的安全保证,又背上维护成本。该用框架时用框架,把精力放在业务权限模型上,才是更划算的做法。对于 微服务治理 场景下多服务共享身份的场景,推荐由网关统一校验 JWT、把解析出的用户信息通过请求头透传给下游,下游服务不再各自对接鉴权,避免重复建设与密钥扩散。
到这一步,一套可上生产的 Spring Security + JWT 鉴权骨架就搭好了:令牌签发收敛在 Provider,身份注入交给过滤器,路由与放行在 SecurityFilterChain 里声明,细粒度权限用 @PreAuthorize 收口。把它和网关、CI 安全测试串起来,Java 后端的认证授权才算真正闭环。




