1. 为什么登录认证会成为项目的拦路虎
先讲一个很典型的场景。某个项目前期赶进度,登录模块直接用Session存用户信息,接口权限靠拦截器里写死的几个URL判断。用户量小的时候一切正常,等要部署到多台服务器做负载均衡时,问题就来了——用户在A服务器登录,请求被负载到B服务器,B服务器查不到Session,直接给你弹回登录页。当时团队里有人说把Session往Redis里存,也有人提议做粘性会话,但每次改完都感觉在给老系统打补丁,代码里到处是session.getAttribute,耦合得没法看。
其实这个问题的本质,是HTTP协议本身不记得你是谁。每次请求都是独立的,服务器需要一种机制来识别“这个人已经登录过了”。传统的Session方案是一种标识,JWT也是一种标识,但两种方案在分布式场景下的表现完全不同。
我在好几个项目里用JWT配合Spring Security做过登录认证和权限校验,这一套组合成熟度高、资料也全,属于Java后端做API认证的主流方案。这篇文章把从零搭建的完整过程、踩过的坑、以及权限设计思路完整梳理一遍,包含可以直接复制的代码和配置,适合正在做接口鉴权、或者想把老项目从Session改造为无状态认证的开发者。
先说几个关键结论,后面会逐一展开:
| 维度 | Session方案 | JWT方案 |
|---|---|---|
| 存储位置 | 服务器内存或Redis | 客户端保存令牌 |
| 扩展性 | 多实例需要共享存储 | 天然支持水平扩展 |
| 性能 | 每次请求查存储 | 验签即可,无IO开销 |
| 失效控制 | 服务端直接销毁 | 只能等令牌过期 |
| 适合场景 | 传统Web应用 | API接口、前后端分离 |
JWT的核心思路,是把用户身份信息经过签名处理后交给客户端保存,服务器不再记忆“谁登录过”,只负责验证令牌是否合法。这样每台服务器都能独立完成认证,不用共享Session数据,水平扩展变得非常轻松。
不过JWT也不是银弹,后面会讲到它的失效痛点,以及我常用的弥补方案。
2. 从Session到JWT,为什么这种迁移值得做
2.1 分布式会话问题的本质原因
Session方案之所以在集群环境下让人头疼,根源在于“会话状态保存在单台服务器”这件事。服务器A保存了用户的会话数据,服务器B却对此一无所知。为了解决这个问题,常见的补救手段包括:
- 粘性会话:让同一个用户的请求始终打到同一台服务器,但这样负载均衡失效,一台机器挂了,那台机器上的所有用户都得重新登录。
- Session复制:服务器之间同步会话数据,节点多了以后广播风暴会拖垮网络。
- 集中式Session存储:把Session放进Redis这类中间件,能解决问题但引入了额外依赖,每次请求都多一次IO。
从架构演进的角度看,这些方案都不是错,只是它们都在围绕“有状态”做文章。JWT的思路完全不同,它把状态转移到了客户端,服务器退化成纯粹的验签服务,这从根本上消除了分布式会话的一致性问题。
这不仅是一种技术选择,也是一种架构思维的转变:既然状态没法避免,那就别让服务器记着它,让客户端自己带着证据来。
2.2 JWT的结构拆解:为什么它的内容不能随便看
JWT全称是JSON Web Token,它由三部分组成,用点号隔开:
eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ1c2VyMTIzIn0.5KGxqT2uL8mJXfB9s3yZdgYkVyVQxVkTV1kS8OQlRgY第一部分是Header(头部),声明签名算法和令牌类型;第二部分是Payload(载荷),存放用户ID、过期时间、角色等声明信息;第三部分是Signature(签名),用密钥对前两部分的拼接结果进行哈希计算。
要注意一个容易误解的点,JWT的内容是Base64编码,不是加密。任何人拿到令牌都可以解码看到Payload里的明文,所以不要把密码、手机号这类敏感信息放进JWT的Payload里。签名只保证内容没被篡改,不保证内容不可见。这一点除了在上线前的安全评审明确提过之外,我也在代码注释里做了强调。
Expiration Time)等。
{ "sub": "10086", "name": "username", "role": "ADMIN", "iat": 1735689600, "exp": 1735693200 }exp字段尤其重要,这是JWT生命周期的根基。签发的时候要留合理的过期时间,太短影响体验,太长增加令牌被盗后的风险窗口。下面会看到,我在实际项目里通常是把访问令牌的过期时间设计得比较短,再用刷新令牌机制来续期。
2.3 Spring Security在这套组合里扮演什么角色
Spring Security提供的是一整套安全过滤链路,JWT只是替换了链路中的“认证信息来源”。框架本身负责拦截请求、判断是否需要认证、管理SecurityContext,而JWT负责回答“当前请求的用户是谁、有哪些权限”。
外层是Spring Security的过滤器链,内层是JWT的校验逻辑,两者各司其职。典型的工作流程是:
- 请求进入Spring Security的过滤器链。
- 经过登录过滤器时校验用户名密码,成功后签发JWT。
- 后续请求携带JWT在请求头里,JWT过滤器负责解析、验签、把用户信息放进SecurityContext。
- Spring Security基于SecurityContext里的信息做授权判断。
这样的分层设计让认证和授权解耦,JWT只管“你是谁”,Spring Security管“你能干什么”。后面集成时,核心工作其实集中在两处:写一个JWT过滤器,以及配置SecurityFilterChain。
3. 最小可用实现:Spring Security加JWT的落地配置
这里的代码基于Spring Boot 3.x和Spring Security 6.x,整体结构走的是目前主流的分层方式。我会把每一层拆开讲清楚,包括为什么要这样写。
3.1 依赖引入与基础配置
Maven项目在pom.xml里加入下面几个依赖:
<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> <dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt-api</artifactId> <version>0.11.5</version> </dependency> <dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt-impl</artifactId> <version>0.11.5</version> <scope>runtime</scope> </dependency> <dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt-jackson</artifactId> <version>0.11.5</version> <scope>runtime</scope> </dependency>JJWT这个库的API设计比较清晰,jjwt-api是编译期需要的接口,jjwt-impl和jjwt-jackson在运行时提供实现。Jackson版本是为了让JJWT能自动处理JSON序列化和反序列化。
引入Spring Security后,默认行为是拦截所有请求,因此需要显式配置SecurityFilterChain。先给一个最基础的配置类:
@Configuration @EnableWebSecurity public class SecurityConfig { @Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http .csrf(csrf -> csrf.disable()) .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .authorizeHttpRequests(auth -> auth .requestMatchers("/api/auth/**").permitAll() .anyRequest().authenticated() ); return http.build(); } }几个配置项的含义分别解释一下:
csrf.disable():前后端分离模式下,CSRF防护主要靠令牌机制而非Cookie,所以关闭CSRF。如果项目还依赖Cookie做会话管理,不能直接关。SessionCreationPolicy.STATELESS:告诉Spring Security不要创建Session,这是无状态认证的开关。/api/auth/**放行:登录、刷新令牌这些接口不需要认证。- 其余接口全部要求认证。
3.2 定义JWT工具类:签发、解析、验签
JWT工具类承担了令牌的创建和校验逻辑。密钥选择上,用HS256对称算法,密钥在配置文件中管理。
@Component public class JwtUtil { @Value("${jwt.secret}") private String secret; @Value("${jwt.expiration}") private Long expiration; private SecretKey getSigningKey() { byte[] keyBytes = Decoders.BASE64.decode(secret); return Keys.hmacShaKeyFor(keyBytes); } public String generateToken(Long userId, String username, List<String> roles) { Date now = new Date(); Date expiryDate = new Date(now.getTime() + expiration); return Jwts.builder() .setSubject(String.valueOf(userId)) .claim("username", username) .claim("roles", roles) .setIssuedAt(now) .setExpiration(expiryDate) .signWith(getSigningKey(), SignatureAlgorithm.HS256) .compact(); } public Claims parseToken(String token) { return Jwts.parserBuilder() .setSigningKey(getSigningKey()) .build() .parseClaimsJws(token) .getBody(); } public boolean validateToken(String token) { try { parseToken(token); return true; } catch (JwtException | IllegalArgumentException e) { return false; } } }要领有几个:jwt.secret必须足够长,HS256算法要求密钥至少256位,常见做法是生成一段Base64编码的随机字符串。expiration用毫秒配置,比如3600000就是一小时。
签发时把用户ID放进sub,用户名和角色放进自定义claim。解析时取出Claims对象,后续过滤器从中提取身份信息。
3.3 JWT过滤器:把令牌转化为SecurityContext
这是整个链路里最关键的一环。JwtAuthenticationFilter继承OncePerRequestFilter,保证每个请求只执行一次:
@Component public class JwtAuthenticationFilter extends OncePerRequestFilter { @Autowired private JwtUtil jwtUtil; @Autowired private UserDetailsService userDetailsService; @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { String token = getTokenFromRequest(request); if (StringUtils.hasText(token) && jwtUtil.validateToken(token)) { Claims claims = jwtUtil.parseToken(token); String username = claims.get("username", String.class); UserDetails userDetails = userDetailsService.loadUserByUsername(username); List<String> roles = claims.get("roles", List.class); List<SimpleGrantedAuthority> authorities = roles.stream() .map(SimpleGrantedAuthority::new) .collect(Collectors.toList()); UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(userDetails, null, authorities); authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); SecurityContextHolder.getContext().setAuthentication(authentication); } filterChain.doFilter(request, response); } private String getTokenFromRequest(HttpServletRequest request) { String bearerToken = request.getHeader("Authorization"); if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer ")) { return bearerToken.substring(7); } return null; } }过滤器做的事情可以概括为五步:
- 从
Authorization请求头里取出Bearer开头的令牌。 - 验签并解析Claims。
- 从Claims里拿用户名,加载对应的UserDetails。
- 把角色转换成Spring Security的
GrantedAuthority,并构造认证对象。 - 将认证对象放入SecurityContextHolder,供后续授权判断使用。
细心的读者会发现这里又查了一次数据库(loadUserByUsername)。有些人会质疑:JWT不就是为了免查询吗?这里查一次是为了拿到最新的用户状态,比如用户被禁用、密码被修改等情况。如果完全信任JWT里的信息,可以在角色之外把用户ID也放进去,直接用claims构造UserDetails,省掉一次查询。但安全性会打折扣,具体取舍视项目而定。
我在这类项目里比较常见的做法:用户量不大、并发不高时优先保证实时性;如果对性能要求更高,可以把UserDetails从Redis里缓存。
3.4 登录接口:认证成功签发令牌
Spring Security的登录方式有两种,一种是用框架自带的AuthenticationManager,一种是手动校验后生成令牌。我习惯走后者,逻辑更裸、好调试。
@RestController @RequestMapping("/api/auth") public class AuthController { @Autowired private AuthenticationManager authenticationManager; @Autowired private JwtUtil jwtUtil; @PostMapping("/login") public ResponseEntity<?> login(@RequestBody LoginRequest loginRequest) { Authentication authentication = authenticationManager.authenticate( new UsernamePasswordAuthenticationToken( loginRequest.getUsername(), loginRequest.getPassword() ) ); UserPrincipal userPrincipal = (UserPrincipal) authentication.getPrincipal(); List<String> roles = userPrincipal.getAuthorities().stream() .map(GrantedAuthority::getAuthority) .collect(Collectors.toList()); String token = jwtUtil.generateToken( userPrincipal.getId(), userPrincipal.getUsername(), roles ); return ResponseEntity.ok(new LoginResponse(token, userPrincipal.getUsername(), roles)); } }authenticationManager.authenticate()调用内部会经过UserDetailsService加载用户、PasswordEncoder校验密码。如果用户名不存在或密码错误,抛出对应的AuthenticationException,由全局异常处理器转成合适的HTTP状态码。
登录成功后返回的token就是客户端后续请求的通行证。客户端需要在每次请求的头里带上:
Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.xxx.yyy这里有个细节:不要把令牌放到URL参数里。URL会被浏览器历史、服务器日志、代理日志记录,令牌泄露风险极高。请求头是标准做法。
3.5 将JWT过滤器接入SecurityFilterChain
现在把写好的JWT过滤器挂到安全过滤链上。需要在SecurityConfig里注册:
@Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http .csrf(csrf -> csrf.disable()) .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .authorizeHttpRequests(auth -> auth .requestMatchers("/api/auth/**").permitAll() .requestMatchers("/api/admin/**").hasRole("ADMIN") .anyRequest().authenticated() ) .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class) .exceptionHandling(ex -> ex .authenticationEntryPoint(restAuthenticationEntryPoint) ); return http.build(); }addFilterBefore表示把JWT过滤器放在用户名密码认证过滤器之前执行。执行顺序在Spring Security里很重要:JWT过滤器必须先解析并把身份放进SecurityContext,后面的授权机制才能读取到角色信息。
如果令牌无效或缺失,请求会到达authenticationEntryPoint。默认会返回302重定向到登录页面,这对纯API项目很不友好。所以要自定义一个返回401的EntryPoint:
@Component public class RestAuthenticationEntryPoint implements AuthenticationEntryPoint { @Override public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException { response.setContentType("application/json;charset=UTF-8"); response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); response.getWriter().write("{\"code\":401,\"message\":\"未认证或令牌已过期\"}"); } }至此,一个最小的JWT + Spring Security认证闭环就通了。不出意外,接下来就是各种意外。
4. 实测中的排错链路:从400到401再到403
集成这类安全框架,大多数时间其实不是在写业务代码,而是在排查“为什么这个请求被拦了”以及“为什么这个请求没被拦”。
4.1 现象一:AccessToken发下来了,但只要带上就401
登录接口正常返回了token,但把token放进Postman的Authorization头再请求受保护接口,还是返回401。翻日志经常只看到一行信息,根本定位不了问题。
我的排查链路如下:
- 先在JWT过滤器里加日志,确认过滤器是否执行。没执行,说明过滤器没挂进FilterChain。
- 确认执行了,打印token本身,确认取到的token是不是带上了“Bearer ”前缀。
- 手动用
parseToken方法验证token能不能正常解析。 - 检查SecurityContext在过滤器执行后是否真的set进去了。
- 最后一步,排查
UserDetailsService里是不是返回了空权限或用户压根没查出来。
这一步最常踩的坑有两个:
第一是Authorization头写成了authorization,HTTP头大小写其实不敏感,但如果用的是自定义的Filter里自己getHeader,通常没问题,问题更容易出在代理服务器上。第二是取token时忘了去掉Bearer前缀,直接用完整字符串解析,必然失败。
还有一个容易被忽略的点:如果在JWT过滤器里用loadUserByUsername重新查库,而这个用户已经不存在,或者isEnabled()返回false,一样会认证失败。对于登录后马上被删号的测试场景,这是最常见的隐藏原因之一。
4.2 现象二:过滤器里抛异常,Spring Boot默认返回500
有一次在接口里故意传了一个篡改过的token,期望返回401,结果返回了500。原因是JWT校验时抛出的JwtException没有被处理,直接从过滤器链里抛出去,Spring Boot的默认错误处理把它映射成了500。
解决办法是在过滤器内部try-catch所有异常,保证任何解析失败都走“未认证”分支,而不是把异常往上抛:
try { if (StringUtils.hasText(token) && jwtUtil.validateToken(token)) { // ...通过认证 } } catch (Exception e) { // 认证失败,保持匿名状态 SecurityContextHolder.clearContext(); logger.debug("JWT认证失败: {}", e.getMessage()); }这里保持匿名状态意味着请求会继续向下走,最终被authenticationEntryPoint拦截,返回401。语义上正确,也不泄露内部细节。
还有一点,把异常信息原样返给前端是不建议的。生产环境下只需返回“未认证或令牌已过期”,不要返回具体是签名不匹配还是过期时间不对,避免给攻击者提供试探信息。
4.3 现象三:明明加了Security,放行接口还是被拦截
配置了/api/auth/**放行,但请求登录接口还是被拦。检查点首先看requestMatchers的写法,确认AntPath匹配规则是否符合预期。Spring Security 6.x的匹配规则更倾向于精确模式,区分大小写,且路径匹配不像之前那么宽松。
其次,注意配置顺序。Spring Security的授权规则按声明顺序依次匹配,先匹配到的规则优先生效。所以更宽松的放行规则要写在前面:
.authorizeHttpRequests(auth -> auth .requestMatchers("/api/auth/**", "/public/**").permitAll() .requestMatchers("/api/admin/**").hasRole("ADMIN") .anyRequest().authenticated() )如果anyRequest().authenticated()写在了permitAll()前面,后面再写什么规则都不会影响最终结果,因为所有请求已经被任意请求认证拦截了。
注意匹配顺序在authorizeHttpRequests里的含义:它是按声明顺序从上到下,一旦匹配到就会终止后续判断。
4.4 现象四:CORS跨域配置后依然跨域失败
前端部署在另一个域名(某独立域名的前端站点),请求后端接口时报跨域错误。Spring Security的CORS配置要做两件事:
http .cors(cors -> cors.configurationSource(corsConfigurationSource())) // ... @Bean public CorsConfigurationSource corsConfigurationSource() { CorsConfiguration config = new CorsConfiguration(); config.setAllowedOrigins(List.of("https://frontend.example.com")); config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS")); config.setAllowedHeaders(List.of("Authorization", "Content-Type")); config.setAllowCredentials(true); UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); source.registerCorsConfiguration("/**", config); return source; }注意setAllowedOrigins里不能写*,这里是实际配置示例,需要替换为真实前端域名。如果设了allowCredentials(true)又用*,不是标准规范允许的组合,运行时会被明确拒绝。另一个细节是OPTIONS预检请求需要放行,否则跨域请求会直接失败。
我之前遇到过一个隐蔽问题:CORS配置表面都正确,但Spring Security的CORS过滤器和JWT过滤器执行顺序没配对,预检请求因为没有Authorization头,被当成了未认证请求拦截。排查时给OPTIONS请求单独放行:
.requestMatchers(HttpMethod.OPTIONS, "/**").permitAll()这不一定是首选方案,但对排查跨域问题很有帮助,确认根因后再收敛规则。
4.5 现象五:Redis缓存冲突覆盖了用户权限
如果项目里本来就有Redis缓存,直接用@Cacheable缓存用户信息,很可能出现权限更新后、缓存未失效导致的新旧权限混乱。这不是JWT本身的问题,而是引入缓存后的数据一致性问题,但在集成过程中很容易踩到,因为JWT的用户加载逻辑往往会顺手套个缓存。
我的做法是:权限变更、角色变更、禁用用户时,显式清理对应用户的缓存条目。推荐用Spring Cache抽象配合@CacheEvict,在用户管理接口里触发清理逻辑:
@CacheEvict(value = "userCache", key = "#userId") public void updateUserRoles(Long userId, List<String> newRoles) { // 更新数据库中的角色关系 }如果是分布式环境需要考虑缓存失效广播,简单的做法是设置一个较短的过期时间兜底,比如5分钟。
5. 权限模型进阶:从hasRole到自定义权限校验
5.1 内置表达式:hasRole、hasAuthority、hasAnyRole
JWT的claims里存了roles列表,Spring Security把这串列表转换成SimpleGrantedAuthority后,就可以用各种表达式做授权判断:
| 表达式 | 说明 |
|---|---|
hasRole("ADMIN") | 角色是ADMIN,自动拼接ROLE_前缀 |
hasAuthority("ROLE_ADMIN") | 权限标识等于ROLE_ADMIN,不自动拼接 |
hasAnyRole("ADMIN", "USER") | 满足任意一个角色即可 |
hasAnyAuthority("ROLE_ADMIN", "ROLE_USER") | 满足任意一个权限即可 |
permitAll() | 放行 |
denyAll() | 拒绝 |
authenticated() | 已登录即可访问 |
这里最容易被绕晕的是ROLE_前缀。如果JWT里存的角色名是ADMIN,用hasRole("ADMIN")时会匹配ROLE_ADMIN;如果用hasAuthority("ADMIN"),匹配的是字符串ADMIN。用hasRole时要保证你的角色命名和Spring Security的预期一致,否则看到一个“权限不足”的403会调试很久。
我在项目里喜欢统一角色命名规范:数据库里存ADMIN、USER,转为authority时统一加ROLE_前缀,授权表达式一律用hasRole。等到了后面业务复杂起来,就切换到基于权限点的设计。
5.2 方法级安全:用注解做细粒度控制
URL级别的配置适合粗粒度控制,但实际业务里经常有这样的需求:同一个接口,普通用户只能访问自己的数据,管理员可以访问所有人的数据。这时用URL匹配写起来很绕,方法级安全注解更合适。
在配置类或启动类上开启注解支持:
@EnableMethodSecurity然后在方法上直接声明权限:
@GetMapping("/orders/{id}") @PreAuthorize("hasRole('ADMIN') or @orderSecurity.isOwner(principal.id, #id)") public Order getOrder(@PathVariable Long id) { return orderService.getOrder(id); }@PreAuthorize里的表达式可以用SpEL写逻辑,上面的@orderSecurity.isOwner()调用一个Spring Bean里的方法实现归属校验。这样“管理员或本人”的复杂规则可以在一个表达式里表达。
方法级安全适合在一个方法有多个接口入口、或者校验逻辑和业务强相关的场景。不过要留意,@PreAuthorize在方法内部执行,所以如果方法内部调用了其他方法,注解不会自动传播。
权限注解的表达式里可以通过principal拿到当前认证用户,这是内置的。拿到之后就可以非常方便地实现资源归属校验。
5.3 动态权限:从数据库加载权限配置
权限完全写死在注解里,对很多业务系统不够灵活。管理员希望能在后台动态调整角色权限,不需要重新部署代码。
我踩过这条路的坑,实现方案其实不复杂。大致思路是:
- 数据库建三张表:用户、角色、权限,以及它们之间的关联表。
- 用户登录时一次性加载所有权限,拼成authorities列表放进JWT里。
- 授权判断时同时校验URL匹配的权限点。
用Spring Security和JWT组合实现动态权限需要注意:JWT里的权限点是签发时快照,改完权限之后,已签发的令牌要等过期才失效。如果要立即生效,要么缩短令牌有效期,要么加一层权限版本号——用户权限版本变了就强制重新登录。
权限实时性和无状态认证是一对天然的矛盾,项目里需要做一个取舍,并且明确写进设计文档。
如果业务要求比较强的一致性,又不想回到有状态Session,可以选择一个中间态:把JWT里的角色、权限全部删掉,只留用户ID。权限判断接口每次从权限服务实时查询。这样又回到了每次请求多一次查询,但对权限实时性要求高的场景是合理的代价。
通用的建议是:先把角色放到JWT里,权限判断走URL和方法注解,手动在管理后台同步处理权限变更,配合短过期时间在可接受范围内实现权限实时性。
6. 令牌生命周期管理实践:过期、刷新与主动失效
6.1 单个令牌的方案隐患
JWT签发后,服务器端没有保存任何状态,所以同一个平行世界里,这个令牌无论被谁拿在手上,只要没过期它就是有效的。这意味着:
- 用户点击“退出登录”,如果只是前端丢弃令牌,那旧令牌依然可以在有效期内使用。
- 一个令牌在A设备泄露,攻击者拿到后在B设备上使用,服务端无感。
- 用户改了密码,旧令牌(除非实现密码变更版本校验)还是能访问接口。
解决这些问题的思路一般有两个方向:
- 把JWT的会话状态引入回来,比如Refresh Token。
- 缩短访问令牌的有效期,增加刷新机制,让泄露的影响尽量小。
实践中最常见的做法是双Token机制。
6.2 双Token机制:Access Token + Refresh Token
Access Token有效期短,比如15分钟,用来正常访问业务接口。Refresh Token有效期长,比如7天,只用来换取新的Access Token。换新接口的请求频率很低,所以即使Refresh Token结构也是JWT,泄露风险窗口也小得多。
实现上并不复杂:
@PostMapping("/refresh") public ResponseEntity<?> refresh(@RequestBody RefreshTokenRequest request) { String refreshToken = request.getRefreshToken(); // 校验刷新令牌是否有效、是否在黑名单中 if (!jwtUtil.validateToken(refreshToken)) { return ResponseEntity.status(401).body("刷新令牌无效"); } Claims claims = jwtUtil.parseToken(refreshToken); String username = claims.get("username", String.class); // 重新加载用户权限,刷新访问令牌 UserPrincipal userPrincipal = (UserPrincipal) userDetailsService.loadUserByUsername(username); String newAccessToken = jwtUtil.generateToken( userPrincipal.getId(), userPrincipal.getUsername(), userPrincipal.getAuthorities().stream() .map(GrantedAuthority::getAuthority) .collect(Collectors.toList()) ); return ResponseEntity.ok(new LoginResponse(newAccessToken, refreshToken, username)); }Refresh Token的签发和Access Token类似,区别在于expiration更长,而且一般不携带业务权限信息,只保留用户ID。前端检测到401响应后,自动调用刷新接口换新令牌,用户无感知。
6.3 主动失效:黑名单方案的取舍
双Token已经解决了大部分问题,但如果业务要求“退出登录后令牌立即失效”,就只能引入存储了。常见做法是把退出时还在有效期内的令牌ID或过期时间存进Redis黑名单。访问时先检查是否在名单里,在就拦截。
这种方案虽然把无状态变回了有状态,但对特定接口和操作来说是必要的,需要把握好度。全站每个接口每时每刻都查Redis,那就失去了JWT在性能上的优势。我的折中方案是:
- 普通接口完全无状态,信任JWT。
- 修改密码、注销账号、退出登录等敏感操作,才查黑名单。
- 黑名单只在令牌过期时间点清掉对应记录,避免无限膨胀。
HashMap。
写进黑名单的KV结构可以这样设计:
Key: jwt:blacklist:{jti} Value: 1 EXPIRE: 剩余过期秒数jti是JWT的JWT ID,签发时生成,我在项目里用UUID,也算是一次性的解决方案。
冷知识:哪怕不存黑名单,只把Refresh Token的“版本号”或“tokenId”存Redis,关键操作也能实现一定程度的召回,成本低于全量黑名单。
在Spring Security集成JWT的项目里,令牌生命周期管理这部分最容易被忽略。很多人把token一发就完事,直到被安全评审提了一堆问题才回头补。趁着项目早期把这里的架构铺好,后面省的事不止一倍。
7. 安全和性能两手抓:几个关于密钥与令牌结构的补充
7.1 密钥管理:环境变量、配置中心与定期轮换
JWT的密钥直接决定了整个认证体系的安全性。密钥泄露意味着任何人都能自己伪造一个管理员令牌,这是最严重的攻击面之一。一些典型的错误我见过:
- 把
jwt.secret硬编码在代码里,提交到代码仓库。 - 密钥太短,本身不够256位。
- 所有环境共用同一个密钥。
- 密钥泄漏后无法轮换。
生产实践的底线是:密钥放在环境变量或配置中心,不在代码仓库里出现。轮换机制上,可以让新密钥先作为“验证密钥”(能验签旧令牌),等旧令牌全部过期后,再切换为“签发密钥”。JJWT的setSigningKey可以在配置阶段传入多个验签key,这样能平滑过渡:
@Bean public JwtParser jwtParser() { return Jwts.parserBuilder() .setSigningKey(newSigningKey()) .setSigningKey(oldSigningKey()) // 过渡期内也接受旧密钥签发的令牌 .build(); }如果不想动用太复杂的机制,拉长过渡时间、缩短令牌有效期,也能让轮换平稳完成。
7.2 Payload精简原则与性能影响
JWT是放在HTTP Header里传输的,Header整体会参与验签计算,每多一个字段,请求性能就多一分损耗。一个几百字节的JWT已经不小了,如果往里面塞一堆自定义claim,每次请求传输的数据量就不可控,对弱网环境体验影响很大。
大致算一下一个常见token的体积:Header加上Payload加上签名,Base64后通常300字节左右。如果塞进去10个自定义字段,体积轻松突破1KB。影响不只是带宽,高并发下服务器验签计算量会随之增加,不过现代CPU做HS256验签很快,真正的瓶颈其实在网络传输。
所以我的建议是Payload里只留必要信息:用户ID、用户名、角色列表、过期时间、签发时间、JWT ID。其他业务信息放到Redis或数据库里按需查询。
还有一个优化点是签名算法选择。HS256是HMAC对称签名,验签快;RS256是非对称签名,验签慢些但更安全,适合第三方服务做认证的场景。一般Spring Boot自建系统的内部认证,用HS256足够,配合密钥安全做到位。
7.3 常见攻击场景的应对
JWT相关的攻击面不少,下面这张表总结了高频问题和对应策略:
| 攻击类型 | 危害 | 应对策略 |
|---|---|---|
| 令牌窃取 | 冒充用户访问接口 | 使用HTTPS、缩短有效期、刷新机制 |
| 算法混淆 | 伪造任意令牌 | 指定算法白名单,禁止从Header读取算法 |
| 暴力破解密钥 | 伪造合法签名 | 使用高强度随机密钥并定期轮换 |
| 重放攻击 | 重复使用旧令牌 | 引入jti检查和黑名单机制 |
| XSS窃取令牌 | 从localStorage拿到令牌 | 对存储位置做权衡,增加CSP防护 |
算法混淆攻击原本是利用alg:none或alg:HS256配合RSA公钥来伪造签名。JJWT 0.11版本的安全机制基本免疫了这类攻击,但仍要留意:使用Jwts.parserBuilder().setSigningKey(key)明确指定密钥时,不要从令牌的Header里动态获取算法信息。
令牌在客户端的存储位置体现的是一种取舍,localStorage方便脚本读取但更容易被XSS利用;HttpOnly Cookie能防XSS读取但不适合纯前端跨域调用。选择哪种方案取决于你的应用形态,我个人在项目里偏向localStorage加严格CSP的组合。
8. 上线前的最后检查:给集成方案兜底
项目上线前,我一般会对照以下清单走一遍,这些是在多个项目里攒下来的检查项:
- 确认
jwt.secret不在代码仓库,已通过环境变量注入。 - 确认所有敏感接口都是HTTPS,不为省事允许HTTP明文传输。
- 确认Spring Security配置里没有把
/api/admin/**这类规则放行。 - 测试过期token、篡改token、无token三种场景的响应码。
- 确认跨域配置在生产环境和实际前端域名匹配。
- 确认日志里没有打印完整token明文,只记录脱敏信息。
- 确认刷新令牌和访问令牌的过期时间比例合理。
- 配置文件里的密钥长度足够,建议至少256位随机字符。
还有一个小小的但很实际的经验:在Git忽略规则里加上配置文件模板,哪怕只是.env.example,不要把真实的secret填进去。默认值、样例值都会成为攻击者的字典素材。
集成认证鉴权这件事实质上是在业务代码之外织一层安全网,交出去之前多审视几遍,比上线后被安全部门通报要省心得多。
每次在项目里做这个改造,都会发现团队成员对JWT的理解差异很大,有人以为它是拿来替代加密的,有人以为服务端什么都不用管。实际上JWT就是一个带签名的凭证,认证、授权、审计这些职责依然需要体系化设计。
最后分享一个小经验:整套“JWT + Spring Security”跑通基本功能可能只需要半天,但从能跑到跑得稳、跑得安全,通常要再花两三倍的时间。别急着把登录功能交付出去,多拿真实场景测一测,尤其把“过期、刷新、退出、权限变更”这几个边界场景过一过。这四关过了,这套认证体系才算真正立住了。