1. 项目概述与核心价值最近在重构一个老的后台管理系统用户登录认证这块一直用的是传统的Session-Cookie方案。随着微服务拆分和前后端分离架构的普及每次请求都要带着Session ID去查Redis不仅增加了网络开销在跨域、分布式环境下更是麻烦不断。正好借着这次重构的机会我决定把认证方案彻底换成JWT。SpringBoot集成JWT听起来是个老生常谈的话题网上教程一抓一大把但真到自己动手时你会发现从依赖选择、密钥管理到Token刷新策略每一步都有不少细节和“坑”需要仔细琢磨。这篇文章我就结合自己这次从零到一的实践把SpringBoot快速集成JWT实现登录认证的完整链路、核心配置以及那些教程里不会写的“踩坑”经验给你一次性讲透。无论你是正在搭建第一个SpringBoot项目的初学者还是想优化现有认证体系的老手这篇内容都能给你提供一份可直接“抄作业”的实操指南。JWT全称JSON Web Token本质上是一个经过数字签名或加密的、包含声明信息的JSON对象。它由三部分组成头部、载荷和签名。在用户登录成功后服务端生成一个JWT Token返回给前端此后前端在每次请求需要认证的接口时只需在HTTP请求头通常是Authorization: Bearer token中携带这个Token。服务端收到后验证Token的签名是否有效、是否过期即可确认用户身份无需再去查询数据库或缓存。这种无状态的特性能轻松应对水平扩展和API网关等场景是构建现代Web应用尤其是SPA和移动端应用的理想选择。2. 技术选型与环境准备2.1 核心依赖库选择在Java生态中处理JWT的库有不少比如jjwt、auth0 java-jwt、nimbus-jose-jwt等。经过一番对比我最终选择了jjwt原因很简单它由Okta维护API设计清晰直观文档齐全社区活跃并且同时支持JWS签名和JWE加密能满足绝大多数场景。对于SpringBoot项目我们通常只需要引入其核心库即可。在你的pom.xml文件中添加以下依赖dependency groupIdio.jsonwebtoken/groupId artifactIdjjwt-api/artifactId version0.11.5/version /dependency dependency groupIdio.jsonwebtoken/groupId artifactIdjjwt-impl/artifactId version0.11.5/version scoperuntime/scope /dependency dependency groupIdio.jsonwebtoken/groupId artifactIdjjwt-jackson/artifactId version0.11.5/version scoperuntime/scope /application这里有个关键点jjwt-impl和jjwt-jackson被设置为runtime作用域。这是因为jjwt-api已经定义了所有我们编码时需要调用的接口而具体实现和JSON处理在运行时才需要。这样设计能让你的编译依赖更干净也符合依赖隔离的最佳实践。版本号0.11.5是一个经过长期验证的稳定版本建议使用。注意千万不要去网上随便搜一个教程就引入一个老版本的jjwt依赖比如0.9.x。新旧版本的API差异巨大老版本的很多用法在新版本中已经废弃盲目复制粘贴会导致代码完全跑不起来。2.2 配置文件与密钥管理JWT的安全性核心在于签名密钥。密钥的生成和管理绝对不能马虎。对于HMAC-SHA算法如HS256我们需要一个足够强壮的密钥。我强烈建议将密钥作为配置项放在application.yml中并且绝不能将真实的密钥提交到代码仓库。# application.yml jwt: secret: your-256-bit-secret # 生产环境务必从环境变量或配置中心读取例如 ${JWT_SECRET:defaultFallbackSecret} expiration: 7200 # Token过期时间单位秒这里设2小时 issuer: your-app-name # 签发者可用于校验这里的secret要求是一个至少256位32字节的字符串。你可以用任何方式生成一个强随机字符串。在生产环境中这个值必须通过环境变量注入如${JWT_SECRET}或者从云服务商的密钥管理服务中获取绝对禁止硬编码。为什么是HS256它是一种对称加密算法使用同一个密钥进行签名和验证性能好实现简单适合单服务或密钥分发安全的场景。如果你的服务是多实例部署且共享同一个密钥配置用HS256没问题。如果你的架构是多个独立的、互不信任的服务需要互相验证Token那么可能需要考虑非对称加密算法如RS256使用公私钥对。不过对于大多数初创项目或内部系统HS256完全够用先跑起来再优化。3. JWT工具类设计与核心实现3.1 构建JWT工具类有了依赖和配置接下来我们封装一个JwtUtil工具类。这个类将负责Token的生成、解析和验证。我把它设计成Spring的Component方便注入配置值。import io.jsonwebtoken.*; import io.jsonwebtoken.security.Keys; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Component; import javax.crypto.SecretKey; import java.util.Date; import java.util.HashMap; import java.util.Map; Component public class JwtUtil { // 签名密钥从配置读取 Value(${jwt.secret}) private String secret; // Token有效期 Value(${jwt.expiration}) private Long expiration; // 签发者 Value(${jwt.issuer}) private String issuer; // 生成安全的密钥对象 private SecretKey getSigningKey() { // 将配置的字符串转换为字节并确保长度足够 byte[] keyBytes secret.getBytes(StandardCharsets.UTF_8); // 使用jjwt提供的Keys工具类生成HMAC-SHA密钥 return Keys.hmacShaKeyFor(keyBytes); } /** * 生成JWT Token * param subject 主题通常放用户ID或用户名 * param claims 自定义声明可以存放角色、权限等信息 * return 生成的Token字符串 */ public String generateToken(String subject, MapString, Object claims) { // 1. 设置Token的过期时间 Date now new Date(); Date expiryDate new Date(now.getTime() expiration * 1000); // 2. 使用Jwts.builder()流畅地构建Token return Jwts.builder() .setClaims(claims) // 设置自定义声明载荷 .setSubject(subject) // 设置主题 .setIssuer(issuer) // 设置签发者 .setIssuedAt(now) // 设置签发时间 .setExpiration(expiryDate) // 设置过期时间 .signWith(getSigningKey(), SignatureAlgorithm.HS256) // 使用HS256算法和密钥签名 .compact(); // 生成最终的字符串 } /** * 从Token中解析出所有声明Claims * param token JWT Token字符串 * return Claims对象包含载荷中的所有信息 */ public Claims parseToken(String token) { // 使用相同的密钥构建解析器并解析Token return Jwts.parserBuilder() .setSigningKey(getSigningKey()) .build() .parseClaimsJws(token) .getBody(); } /** * 验证Token是否有效未过期且签名正确 * param token JWT Token字符串 * return 是否有效 */ public boolean validateToken(String token) { try { parseToken(token); // 如果能成功解析说明签名有效且未过期过期会抛出ExpiredJwtException return true; } catch (JwtException | IllegalArgumentException e) { // 捕获所有JWT相关异常签名无效、过期、格式错误等 return false; } } /** * 从Token中获取主题通常是用户ID */ public String getSubjectFromToken(String token) { return parseToken(token).getSubject(); } /** * 检查Token是否即将过期例如在到期前30分钟内用于触发刷新逻辑 * param token JWT Token字符串 * param minutes 提前多少分钟视为“即将过期” * return 是否即将过期 */ public boolean isTokenExpiringSoon(String token, int minutes) { try { Claims claims parseToken(token); Date expiration claims.getExpiration(); Date now new Date(); // 计算距离过期还有多少毫秒 long timeUntilExpiry expiration.getTime() - now.getTime(); // 如果剩余时间小于指定的分钟数则认为即将过期 return timeUntilExpiry 0 timeUntilExpiry (minutes * 60 * 1000L); } catch (JwtException e) { // 如果Token本身无效那肯定不是“即将过期”的问题了 return false; } } }这个工具类涵盖了核心操作。generateToken方法中我特意将claims参数放在subject之前设置。这是因为setSubject方法内部会往claims里写一个sub字段如果先setSubject再setClaims后者的claims会覆盖掉前者导致sub丢失。这个顺序是很多新手容易踩的坑。3.2 自定义载荷Claims的设计策略JWT的载荷部分可以存放一些公开的信息。标准预定义字段如sub主题、exp过期时间、iat签发时间等很有用。除此之外我们通常会添加一些业务字段。我的经验是放必要且不敏感的信息。一个典型的自定义claims可能像这样MapString, Object claims new HashMap(); claims.put(“userId”, user.getId()); // 用户唯一ID claims.put(“username”, user.getUsername()); // 用户名 claims.put(“roles”, user.getRoles()); // 用户角色列表如 [“ROLE_ADMIN”, “ROLE_USER”] // 注意不要存放密码、手机号等敏感信息Token本身只是Base64编码可以被轻易解码查看。为什么要把角色信息放进Token这样在验证Token有效后我们可以直接从Token中提取用户角色进行授权判断无需再次查询数据库这就是所谓的“无状态授权”能极大提升接口性能。当然这要求角色信息不能频繁变动或者你有配套的Token强制失效机制。4. 集成Spring Security实现认证拦截4.1 引入Spring Security依赖单纯有JWT工具类还不够我们需要一个机制来拦截HTTP请求验证其中的Token。Spring Security是Spring生态中处理安全性的不二之选。在pom.xml中添加dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-security/artifactId /dependency引入后默认所有接口都会被保护访问任何地址都会跳转到一个自带的登录页面。这显然不是我们想要的。我们需要自定义配置。4.2 自定义安全配置类创建一个继承WebSecurityConfigurerAdapter的配置类注意在Spring Security 5.7更推荐使用基于组件的配置但为了兼容性和教程清晰这里仍使用旧版方式原理相通。import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.security.authentication.AuthenticationManager; import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.config.http.SessionCreationPolicy; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; Configuration EnableWebSecurity public class SecurityConfig { Autowired private JwtAuthenticationEntryPoint unauthorizedHandler; Autowired private JwtAuthenticationFilter jwtAuthenticationFilter; Bean public PasswordEncoder passwordEncoder() { // 用于加密用户密码BCrypt是当前推荐的安全哈希算法 return new BCryptPasswordEncoder(); } Bean public AuthenticationManager authenticationManager(AuthenticationConfiguration authConfig) throws Exception { return authConfig.getAuthenticationManager(); } Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http // 禁用CSRF因为JWT是无状态的不依赖CookieCSRF攻击的前提不存在 .csrf().disable() // 设置异常处理器处理认证失败如Token无效的情况 .exceptionHandling().authenticationEntryPoint(unauthorizedHandler) .and() // 因为使用JWT所以不需要Session .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS) .and() // 配置请求授权规则 .authorizeRequests() // 登录、注册、公开API等接口允许匿名访问 .antMatchers(“/api/auth/login”, “/api/auth/register”, “/public/**”).permitAll() // 管理员接口需要ADMIN角色 .antMatchers(“/api/admin/**”).hasRole(“ADMIN”) // 其他所有请求都需要认证即携带有效Token .anyRequest().authenticated(); // 将我们自定义的JWT过滤器加到UsernamePasswordAuthenticationFilter之前 http.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); return http.build(); } }这个配置是核心。SessionCreationPolicy.STATELESS声明了我们的应用是无状态的Spring Security不会创建和使用HttpSession。JwtAuthenticationFilter是我们接下来要写的自定义过滤器它负责从请求头中提取Token并验证。JwtAuthenticationEntryPoint则是一个异常处理器当认证失败比如没带Token或Token无效时它会返回一个结构化的错误JSON而不是跳转到登录页。4.3 实现JWT认证过滤器这个过滤器是连接HTTP请求和JWT验证的桥梁。import org.springframework.beans.factory.annotation.Autowired; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.security.core.userdetails.UserDetails; import org.springframework.security.web.authentication.WebAuthenticationDetailsSource; import org.springframework.stereotype.Component; import org.springframework.util.StringUtils; import org.springframework.web.filter.OncePerRequestFilter; import javax.servlet.FilterChain; import javax.servlet.ServletException; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import java.io.IOException; Component public class JwtAuthenticationFilter extends OncePerRequestFilter { Autowired private JwtUtil jwtUtil; Autowired private CustomUserDetailsService userDetailsService; // 这是一个自定义的服务用于根据用户名加载用户信息 Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { try { // 1. 从请求头中提取JWT Token String jwt parseJwt(request); if (jwt ! null jwtUtil.validateToken(jwt)) { // 2. 从有效的Token中解析出用户名subject String username jwtUtil.getSubjectFromToken(jwt); // 3. 检查Security上下文中是否已存在该用户的认证信息避免重复认证 if (username ! null SecurityContextHolder.getContext().getAuthentication() null) { // 4. 根据用户名加载用户详细信息包括权限 UserDetails userDetails userDetailsService.loadUserByUsername(username); // 5. 构建一个已认证的Authentication对象 UsernamePasswordAuthenticationToken authentication new UsernamePasswordAuthenticationToken( userDetails, null, // 凭证密码在这里不需要因为JWT已证明身份 userDetails.getAuthorities()); // 用户的权限集合 // 6. 将请求的详细信息如IP、Session ID设置到Authentication对象中 authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); // 7. 将Authentication对象设置到Security上下文中代表用户已认证 SecurityContextHolder.getContext().setAuthentication(authentication); } } } catch (Exception e) { // 记录日志但不要在这里抛出异常交给后面的EntryPoint处理 logger.error(“Cannot set user authentication: {}”, e); } // 8. 继续执行过滤器链 filterChain.doFilter(request, response); } private String parseJwt(HttpServletRequest request) { String headerAuth request.getHeader(“Authorization”); if (StringUtils.hasText(headerAuth) headerAuth.startsWith(“Bearer “)) { // 提取“Bearer ”之后的部分即真正的Token return headerAuth.substring(7); } return null; } }这个过滤器的逻辑很清晰检查每个请求的Authorization头如果有有效Token就根据Token中的用户名加载用户权限并完成Spring Security的认证流程。这样在后续的Controller中你就可以通过AuthenticationPrincipal注解或SecurityContextHolder直接获取到当前登录用户的信息。注意这里我调用了userDetailsService.loadUserByUsername。对于完全无状态的场景你可以选择将权限也编码在JWT的claims里在过滤器中直接从Token还原出权限列表从而避免这次数据库查询。但这意味着一旦用户权限变更必须等到其Token过期或主动重新登录才能生效。你需要根据业务对“权限实时性”的要求来做权衡。我的建议是对于后台管理系统权限变更后立即生效是刚需所以这里保留一次查询是值得的。5. 构建登录与用户详情服务5.1 实现自定义UserDetailsServiceCustomUserDetailsService是Spring Security用于加载用户核心信息的接口。我们需要实现它将数据库中的用户实体转化为Spring Security能理解的UserDetails对象。import org.springframework.beans.factory.annotation.Autowired; import org.springframework.security.core.GrantedAuthority; import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.security.core.userdetails.UserDetails; import org.springframework.security.core.userdetails.UserDetailsService; import org.springframework.security.core.userdetails.UsernameNotFoundException; import org.springframework.stereotype.Service; import java.util.List; import java.util.stream.Collectors; Service public class CustomUserDetailsService implements UserDetailsService { Autowired private UserRepository userRepository; // 假设你有一个访问用户表的Repository Override public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { // 从数据库查找用户这里假设username是唯一标识 User user userRepository.findByUsername(username) .orElseThrow(() - new UsernameNotFoundException(“User not found with username: “ username)); // 将数据库中的角色字符串如“ROLE_ADMIN,ROLE_USER”转换为GrantedAuthority集合 ListGrantedAuthority authorities user.getRoles().stream() .map(role - new SimpleGrantedAuthority(role.getName())) // 假设Role实体有getName方法 .collect(Collectors.toList()); // 返回Spring Security的User对象它是UserDetails的实现 // 注意这里返回的User是org.springframework.security.core.userdetails.User return new org.springframework.security.core.userdetails.User( user.getUsername(), user.getPassword(), // 数据库存储的应该是加密后的密码 authorities); } }这里的关键是数据库里存储的用户密码必须是加密后的比如用前面配置的BCryptPasswordEncoder加密的字符串。Spring Security在认证时会自动用相同的编码器进行比对。5.2 创建登录认证接口最后我们需要一个接收用户名密码、验证成功后颁发JWT Token的接口。import org.springframework.beans.factory.annotation.Autowired; import org.springframework.security.authentication.AuthenticationManager; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.Authentication; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.web.bind.annotation.*; import javax.validation.Valid; import java.util.HashMap; import java.util.Map; RestController RequestMapping(“/api/auth”) public class AuthController { Autowired private AuthenticationManager authenticationManager; Autowired private JwtUtil jwtUtil; PostMapping(“/login”) public ResponseEntity? login(Valid RequestBody LoginRequest loginRequest) { // 1. 使用Spring Security的AuthenticationManager进行认证 Authentication authentication authenticationManager.authenticate( new UsernamePasswordAuthenticationToken( loginRequest.getUsername(), loginRequest.getPassword() ) ); // 2. 认证成功将Authentication对象设置到上下文中非必须但符合规范 SecurityContextHolder.getContext().setAuthentication(authentication); // 3. 获取当前认证的用户信息 UserDetails userDetails (UserDetails) authentication.getPrincipal(); // 4. 准备JWT的载荷Claims MapString, Object claims new HashMap(); // 你可以放入任何不敏感的业务信息比如用户ID、昵称、角色等 // 假设我们有一个方法能从userDetails中获取用户ID String userId getUserIdFromUserDetails(userDetails); claims.put(“userId”, userId); claims.put(“roles”, userDetails.getAuthorities().stream() .map(GrantedAuthority::getAuthority) .collect(Collectors.toList())); // 5. 生成JWT Token主题subject通常设为用户名 String jwtToken jwtUtil.generateToken(userDetails.getUsername(), claims); // 6. 构建响应体 MapString, String response new HashMap(); response.put(“token”, jwtToken); response.put(“type”, “Bearer”); response.put(“expiresIn”, String.valueOf(jwtUtil.getExpiration())); // 返回过期时间秒数 return ResponseEntity.ok(response); } // 一个简单的登出接口由于JWT无状态服务端只需让客户端丢弃Token即可 PostMapping(“/logout”) public ResponseEntity? logout() { // 理论上服务端无法主动让一个JWT失效除非使用黑名单机制。 // 这里只是返回成功实际失效操作在客户端删除存储的Token。 SecurityContextHolder.clearContext(); // 清除当前线程的Security上下文 return ResponseEntity.ok(“Logout successful”); } }登录接口的核心是authenticationManager.authenticate()它会委托给我们在CustomUserDetailsService中实现的逻辑进行用户名密码的校验。校验成功后我们利用JwtUtil生成Token并返回。前端收到后通常会将这个Token存储在localStorage或sessionStorage中并在后续请求的Authorization头中携带。6. 高级话题与生产环境考量6.1 Token刷新机制的设计JWT一旦签发在过期前无法修改。如果Token有效期设得太长不安全设得太短用户体验差频繁要求重新登录。折中的方案是使用“刷新Token”。具体流程通常是登录时服务端返回两个Token一个访问TokenAccess Token有效期短如2小时一个刷新TokenRefresh Token有效期长如7天或30天。访问Token过期后客户端不是让用户重新登录而是用一个专门的接口凭刷新Token来获取新的访问Token和可选的新的刷新Token。刷新Token本身也应有过期时间并且一旦使用过旧的刷新Token应失效单次使用防止被盗用。实现刷新Token机制你需要在数据库中或一个独立的缓存中如Redis存储刷新Token与用户ID、设备信息的关联关系并设置过期时间。提供一个/api/auth/refresh接口接收刷新Token验证其有效性和单次性然后颁发新的Token对。刷新Token的生成可以使用JWT但验证时除了签名和过期时间还必须去存储中检查其状态。6.2 安全性增强与常见攻击防护密钥管理重申一遍生产环境的JWT密钥必须通过环境变量或密钥管理服务注入严禁硬编码。Token存储前端不应将Token存放在容易被XSS攻击读取的localStorage中。对于能保证子域安全的场景可考虑存在HttpOnly的Cookie中需妥善处理CSRF防护。更常见的做法是存在sessionStorage中并确保代码没有XSS漏洞。注销与黑名单JWT无法在服务端直接作废。对于需要立即吊销Token的场景如用户修改密码、管理员封禁用户可以维护一个“黑名单”如RedisKey为Token的jti声明或Token本身设置一个较短的过期时间等于原Token剩余有效期。在过滤器中验证Token时增加一步黑名单检查。防止重放攻击可以为每个Token加入一个随机数jti声明并记录但这会引入状态与无状态初衷相悖。一个更实用的方法是缩短Token有效期并配合刷新机制。6.3 性能优化与监控避免频繁查库在JwtAuthenticationFilter中每次请求都调用userDetailsService.loadUserByUsername查库可能会成为性能瓶颈。如果用户权限不常变可以考虑将UserDetails对象不含密码在验证Token后缓存一段时间如几分钟使用用户名作为Key。监控Token使用记录Token的签发、验证失败过期、签名无效、刷新等事件有助于发现异常行为和安全攻击。选择合适的算法HS256性能很好。如果担心密钥泄露风险可以考虑RS256非对称但验证签名时的计算开销会稍大。7. 踩坑实录与排查技巧在实际集成过程中我遇到了几个典型问题这里分享出来帮你避坑。问题一SignatureException: JWT signature does not match locally computed signature.现象登录成功拿到Token但访问其他接口时报签名不匹配。排查检查JwtUtil中getSigningKey()方法确保生成密钥的secret字符串前后没有空格或换行符。最好在配置读取后打印一下长度或进行trim()。确认生成Token和验证Token使用的是完全相同的密钥。在微服务架构中所有服务必须共享同一个密钥配置。检查Token在传输过程中是否被修改。可以用在线工具如 jwt.io 解码你的Token手动验证签名。解决在我的案例中是因为在application.yml里写secret时不小心在值后面加了一个空格。YAML解析会包含这个空格导致密钥不一致。去掉空格后问题解决。问题二ExpiredJwtException但前端明明刚登录。现象登录后立即操作偶尔会提示Token过期。排查检查服务器时间是否正确。JWT的exp是基于服务器系统时间的。如果服务器时间比实际时间快Token就会“提前”过期。检查JwtUtil中计算过期时间expiryDate的逻辑确保单位换算正确配置是秒Date构造函数需要毫秒。在前端和后端打印Token的签发时间(iat)和过期时间(exp)进行对比。解决我们有一台测试服务器时间漂移了5分钟。使用NTP服务同步所有服务器时间后问题消失。问题三Spring Security配置了放行路径但仍被拦截。现象在SecurityConfig中明明配置了.antMatchers(“/public/**”).permitAll()但访问/public/test还是要求认证。排查检查路径匹配是否正确。/public/**可以匹配/public/test和/public/sub/path。如果你的路径是/api/public/test则需要配置/api/public/**。检查过滤器链的顺序。自定义的JwtAuthenticationFilter是否在UsernamePasswordAuthenticationFilter之前如果顺序不对可能先被其他过滤器拦截了。使用Spring Boot Actuator的/actuator/mappings端点查看所有接口的真实映射路径确保和你配置的一致。解决我的问题是Controller类上的RequestMapping(“/api”)和方法上的GetMapping(“public/test”)组合成了/api/public/test而安全配置只写了/public/**自然不匹配。将安全配置改为/api/public/**后解决。问题四跨域CORS请求时前端无法携带Token。现象前端是独立域名发起登录请求成功但后续带Token的请求被浏览器拦截提示CORS错误。排查确保服务端正确配置了CORS。Spring Boot可以通过CrossOrigin注解或全局WebMvcConfigurer配置。重点检查CORS配置是否暴露了Authorization头。浏览器对于跨域请求中的自定义头包括Authorization有特殊要求服务端必须在Access-Control-Expose-Headers响应头中明确列出前端才能读取到。解决在全局CORS配置中添加如下设置Configuration public class WebConfig implements WebMvcConfigurer { Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping(“/**“) .allowedOrigins(“https://your-frontend-domain.com“) .allowedMethods(“GET”, “POST”, “PUT”, “DELETE”, “OPTIONS”) .allowedHeaders(“*“) .exposedHeaders(“Authorization”) // 关键暴露Authorization头 .allowCredentials(true); // 如果使用Cookie需要这个 } }集成JWT到SpringBoot项目从工具类编写、Spring Security配置到过滤器实现每一步都需要对框架和协议有清晰的理解。我的体会是不要急于求成先把最小链路跑通登录-生成Token-携带Token访问一个受保护接口然后再逐步添加刷新机制、黑名单、监控等高级特性。对于大多数应用本文提供的方案已经足够稳健。最后记住安全无小事密钥管理、Token有效期、传输安全这些细节值得你花额外的时间去仔细打磨。