在前后端分离架构中,后端只提供REST API,前端通过Ajax或Fetch调用接口。如果继续使用传统的Session认证,浏览器需要携带Cookie,而Cookie在跨域场景下会面临诸多限制:SameSite策略、CORS预检、CSRF攻击面扩大等。更关键的是,Session本质上是服务端存储的一段状态,当用户量增长需要横向扩展服务时,必须引入Redis等共享存储来同步会话,这增加了架构复杂度。JWT则提供了一种自包含的令牌方案,服务端签发一个带签名的JSON字符串,客户端保存后每次请求附带在Authorization头中,服务端验签即可识别用户身份,全程无需查询存储。

一、JWT令牌的结构与无状态验证原理
JWT全称JSON Web Token,它不是一个加密令牌,而是一个带签名的、可验证的明文令牌。一个完整的JWT由三部分组成:Header、Payload和Signature,它们之间用点号连接,形如xxxxx.yyyyy.zzzzz。Header描述了签名算法,例如HS256;Payload中存放声明信息,比如用户ID、角色、过期时间等;Signature则是对前两部分使用密钥计算出的哈希值,用于防止篡改。任何拿到令牌的人都可以解码出Payload中的内容,但如果没有密钥,就无法伪造合法的签名。
JWT实现无状态鉴权的逻辑非常直接:用户登录成功后,服务端根据用户信息生成一个JWT并返回给客户端。客户端在后续请求的Authorization头中携带该令牌,服务端从请求中解析出JWT,验证签名和过期时间,如果校验通过就把令牌中的用户信息读出来并放入当前请求的安全上下文。这个过程完全不依赖服务端保存的任何会话数据,因此任何一个服务实例都可以独立完成验证,这为水平扩展提供了极大的便利。
// JWT生成过程的伪代码示意
String headerJson = "{\"alg\":\"HS256\",\"typ\":\"JWT\"}";
String payloadJson = "{\"sub\":\"1001\",\"name\":\"zhangsan\",\"exp\":1717000000}";
String header = base64UrlEncode(headerJson.getBytes());
String payload = base64UrlEncode(payloadJson.getBytes());
String content = header + "." + payload;
String signature = HMACSHA256(content, secretKey);
String jwt = content + "." + signature;
上面的伪代码展示了HS256算法下JWT的生成流程,其中base64UrlEncode是对字节数组进行Base64 URL安全编码,HMACSHA256使用密钥对拼接后的字符串做哈希。实际开发中一般会使用JJWT、Nimbus JOSE或Spring Security自带的JWT库来简化操作,但理解底层原理有助于排查验签失败、过期判断等问题。
二、Spring Security整合JWT所需的核心过滤器
Spring Security默认的认证入口是UsernamePasswordAuthenticationFilter,它从表单参数中读取用户名和密码,然后交给AuthenticationManager验证。在前后端分离场景下,客户端通常不会提交表单,而是通过JSON传递登录信息,认证成功后的后续请求也不再携带Cookie,而是在Header中放置JWT。因此我们需要自定义一个过滤器,替换或前置默认的认证逻辑,让它从Authorization头中提取Bearer令牌并完成身份恢复。
实现这个过滤器最常用的做法是继承OncePerRequestFilter,保证每个请求只执行一次过滤逻辑。过滤器的核心流程分为三步:第一步从请求头中取出Authorization值,并检查是否以Bearer开头;第二步去掉前缀得到纯JWT字符串,调用JWT工具类进行解析和验签;第三步将解析出的用户信息封装为UsernamePasswordAuthenticationToken,并写入SecurityContextHolder。如果解析失败或令牌过期,直接向响应写入401状态码并结束请求,不再继续过滤器链。
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final JwtUtil jwtUtil;
public JwtAuthenticationFilter(JwtUtil jwtUtil) {
this.jwtUtil = jwtUtil;
}
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain)
throws ServletException, IOException {
String authHeader = request.getHeader("Authorization");
if (authHeader != null && authHeader.startsWith("Bearer ")) {
String token = authHeader.substring(7);
try {
String username = jwtUtil.extractUsername(token);
if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) {
UserDetails userDetails = userDetailsService.loadUserByUsername(username);
if (jwtUtil.validateToken(token, userDetails)) {
UsernamePasswordAuthenticationToken authentication =
new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(authentication);
}
}
} catch (Exception e) {
response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Invalid or expired token");
return;
}
}
filterChain.doFilter(request, response);
}
}
上述代码中JwtUtil负责生成、解析和验证JWT,userDetailsService用来从数据库或内存中加载用户详情。注意在设置安全上下文之前先检查SecurityContextHolder.getContext().getAuthentication() == null,避免对已经认证的请求重复处理。另外异常捕获后直接返回,防止无效令牌继续向后传递,这符合无状态接口快速失败的思路。
JWT工具类需要具备三个核心能力:根据用户名和权限生成令牌、从令牌中提取用户名、校验令牌签名和过期时间。推荐使用成熟的第三方库(例如JJWT 0.11.x),因为手动实现签名校验容易出错,尤其是算法混淆攻击。下面给出一个基于JJWT的最小实现示例,其中secretKey建议从配置中心读取,并且长度要足够长,HS256要求至少256位密钥。
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import io.jsonwebtoken.security.Keys;
import javax.crypto.SecretKey;
import java.util.Date;
import java.util.HashMap;
import java.util.Map;
public class JwtUtil {
private final SecretKey secretKey = Keys.secretKeyFor(SignatureAlgorithm.HS256);
private final long expiration = 1000 * 60 * 60 * 2; // 2小时
public String generateToken(UserDetails userDetails) {
Map<String, Object> claims = new HashMap<>();
claims.put("roles", userDetails.getAuthorities());
return Jwts.builder()
.setClaims(claims)
.setSubject(userDetails.getUsername())
.setIssuedAt(new Date())
.setExpiration(new Date(System.currentTimeMillis() + expiration))
.signWith(secretKey)
.compact();
}
public String extractUsername(String token) {
return extractAllClaims(token).getSubject();
}
public boolean validateToken(String token, UserDetails userDetails) {
final String username = extractUsername(token);
return username.equals(userDetails.getUsername()) &&
extractAllClaims(token).getExpiration().after(new Date());
}
private Claims extractAllClaims(String token) {
return Jwts.parserBuilder()
.setSigningKey(secretKey)
.build()
.parseClaimsJws(token)
.getBody();
}
}
注意上面代码中Map<String, Object>和new HashMap<>()的尖括号在HTML源码中做了转义,实际Java代码里就是常规泛型写法。该工具类的validateToken方法不仅检查用户名是否一致,还判断令牌是否已经过期。生产环境还需要处理令牌格式错误、签名不匹配等异常,避免让异常信息直接暴露给客户端。
三、安全配置与放行规则
有了JWT过滤器和工具类之后,接下来需要修改Spring Security的配置类。关键点有三个:一是关闭CSRF防护,因为前后端分离场景下没有Cookie,CSRF攻击的前提不成立;二是将Session创建策略设置为STATELESS,彻底禁用HttpSession,避免服务端保存状态;三是把自定义的JwtAuthenticationFilter添加到UsernamePasswordAuthenticationFilter之前,确保在默认表单认证之前执行JWT解析逻辑。
放行规则也需要仔细设计,通常登录接口、注册接口以及一些公开资源需要允许匿名访问,而其他接口一律要求认证。下面是一个典型的配置类示例,其中登录接口会调用AuthenticationManager验证用户名密码,成功后生成JWT并返回给前端。
@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
private final JwtUtil jwtUtil;
private final UserDetailsService userDetailsService;
public SecurityConfig(JwtUtil jwtUtil, UserDetailsService userDetailsService) {
this.jwtUtil = jwtUtil;
this.userDetailsService = userDetailsService;
}
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.userDetailsService(userDetailsService).passwordEncoder(passwordEncoder());
}
@Override
protected void configure(HttpSecurity http) throws Exception {
http.csrf().disable()
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
.authorizeRequests()
.antMatchers("/login", "/register", "/public/**").permitAll()
.anyRequest().authenticated()
.and()
.addFilterBefore(new JwtAuthenticationFilter(jwtUtil, userDetailsService),
UsernamePasswordAuthenticationFilter.class);
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean
@Override
public AuthenticationManager authenticationManagerBean() throws Exception {
return super.authenticationManagerBean();
}
}
配置类中antMatchers指定的路径需要与实际的Controller映射一致。如果项目使用Spring Boot 3及以上版本,WebSecurityConfigurerAdapter已被弃用,应改用SecurityFilterChainBean配置,但思路完全相同。此外,密码编码器推荐使用BCryptPasswordEncoder,登录时通过AuthenticationManager进行校验,校验成功后调用jwtUtil.generateToken生成令牌返回。
放行规则越精确越好,不要使用permitAll覆盖太宽的范围。如果存在未受保护的API,攻击者可能绕过认证直接访问敏感数据。对于静态资源,如果由前端独立部署,后端通常不需要处理,因此可以完全关闭静态资源的权限配置,让所有请求都走JWT认证。
四、常见问题与生产环境避坑
无状态JWT鉴权虽然简洁,但在实际生产环境中会遇到几个典型的挑战。首先是令牌过期问题:如果access token有效期设置得太长,一旦泄露会造成较长时间的安全窗口;如果设置得太短,用户频繁被踢出体验很差。常见的解决思路是双Token机制,access token保持较短有效期(例如15分钟到2小时),refresh token有效期较长(例如7天或30天),当access token过期时前端用refresh token去换取新的access token,refresh token本身不参与普通API请求,从而降低泄露风险。
退出登录是无状态鉴权的另一个痛点。因为服务端不保存任何会话,无法主动使某个JWT失效。要解决这个问题,可以引入黑名单机制,例如将退出用户的JWT的jti或签名部分存入Redis,并在过滤器解析时检查黑名单。或者干脆缩短access token的有效期,让令牌自然过期。对于需要实时踢人、强制下线的系统,还需要配合WebSocket推送或其他通知机制,才能实现比较完整的会话管理。
密钥管理也是容易忽视的风险点。JWT的签名密钥如果硬编码在代码中或放在公开仓库,攻击者拿到密钥后可以任意伪造令牌。生产环境应将密钥放入环境变量、Vault或配置中心,并定期轮换。轮换密钥时要考虑旧令牌的兼容性,通常采用双密钥验证策略:新令牌用新密钥签发,旧令牌在一段时间内仍可用旧密钥验证,过渡期结束后废弃旧密钥。
最后,前端存储JWT的方式也有讲究。将JWT保存在localStorage中容易受到XSS攻击,一旦页面存在脚本注入漏洞,攻击者可以窃取令牌。使用httpOnly Cookie虽然能防止JavaScript读取,但又回到了跨域Cookie的老问题。常见的折中方案是:如果前端和后端同域部署,优先使用httpOnly Secure Cookie存储JWT,并配置SameSite=Strict或Lax;如果前后端完全分离且跨域,只能在localStorage中存储,此时必须严格过滤用户输入、使用CSP策略降低XSS风险,同时缩短令牌有效期以减少损失。
综合来看,Spring Security结合JWT实现前后端分离无状态鉴权是一套成熟且广泛使用的方案。它解决了Session扩展性差、跨域CSRF复杂的问题,但也引入了令牌失效管理、密钥安全等新的挑战。只有理解底层运行机制,才能根据业务场景做出合理的取舍,构建出既安全又易维护的认证体系。
Spring SecurityJWT前后端分离修改时间:2026-10-03 23:11:44