如果你做过服务端接口开发一定绕不过一个问题登录之后的用户身份怎么识别。我在前后端分离的项目里反复对比过几种方案最后用下来最顺手的就是SpringBoot整合JWT。JWT全称JSON Web Token是一种紧凑的、基于JSON的令牌规范把用户身份信息和签名打包成一个字符串服务端不需要保存会话拿到Token验签就能认人。这篇文章适合正在做前后端分离、小程序后台、微服务接口认证的同学也适合那些想搞清楚Token原理、准备自己动手集成SpringBoot的新手。我会从为什么用JWT讲起再把SpringBoot里几个常见的整合姿势拆开最后把容易踩的坑一次性说清楚。1. 为什么做SpringBoot整合JWT从Session到Token的演进1.1 传统会话认证的痛点传统Session方案里用户登录成功后服务端会把Session信息保存在内存或Redis中然后把一个JSESSIONID写进Cookie。浏览器后续请求会自动带上这个Cookie服务端拿着Session ID去查对应的会话记录查到了就认为用户已登录。这套机制在单体应用时代非常自然几乎没有感知。但一旦进入前后端分离、多端并行开发的阶段麻烦就来了。第一是跨域问题Cookie跨域需要额外配置域名一多各种CORS和SameSite策略能把人绕晕。第二是扩展问题Session默认存在单机内存里服务一旦做成多实例部署用户的请求可能被负载均衡分到另一台机器Session就丢了必须额外引入Session共享或者粘滞会话。第三是移动端适配问题App、小程序这些客户端对Cookie的支持远不如浏览器友好开发者经常需要手动处理会话标识的保存和传递。更本质的问题是这种方式让服务端接口强依赖“会话状态”。API本身不自治每次都要查一次状态这个状态既占存储又把认证逻辑和业务逻辑纠缠在一起。后期想拆微服务单点登录、跨服务共享登录态一套Session复制方案往往比业务还复杂。1.2 JWT能在哪些场景发挥优势JWT的核心价值在于无状态和自包含。服务端不保存会话Token里本身就带着用户ID、过期时间、自定义声明等信息并且用签名保证内容没有被篡改。验签通过就相当于完成了身份认证。这个特性让它特别适合三类场景。第一类是前后端分离项目前端把Token放在请求头的Authorization字段里天然避开Cookie跨域问题。第二类是微服务或开放接口认证服务负责签发Token其他业务服务只要共享相同的验签规则就能独立校验请求身份不用每次都去认证中心查Session。第三类是手机App、小程序这类非浏览器端Token只是一个字符串存哪儿都方便不会像Cookie一样受浏览器策略限制。但也要冷静看待JWT并不是银弹。它无法主动失效签发出去之后在过期之前理论上一直有效。Token要携带用户信息长度比普通Session ID大不少。我见过有人把所有权限列表都塞进JWT结果请求头直接膨胀到几KB网关层和日志系统都跟着难受。所以后面我们讲落地时会把“短生命周期刷新令牌必要的黑名单”这套组合一起安排上。2. 先把JWT的原理吃透Header、Payload、Signature2.1 三段式结构的拆解JWT字符串看起来是一串用点号分隔的三段内容大概长这样eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c第一部分是Header内容通常包含两个字段alg表示签名算法比如HS256typ固定为JWT。第二部分是Payload也叫Claims用来放实际的数据包括标准声明sub主题、iat签发时间、exp过期时间也可以放自定义字段比如userId、username。第三部分是Signature它是由Header、Payload和密钥一起计算出来的签名结果。很多人第一次看到JWT会误以为它是加密的。其实不是前两段只是做了Base64Url编码用解码工具一解就能看到明文内容。所以JWT里的信息人人可读能不能被信任完全靠最后的签名来保证。我习惯用一个比喻JWT像一张带有防伪标识的通行证上面印着姓名和有效期别人能看见内容但想改名字或者篡改有效期必须能重新生成防伪标识而防伪标识的生成密钥只掌握在签发方手里。2.2 签名算法的选择HS256还是RS256签名算法直接决定了JWT的安全模型最常见的两个是HS256和RS256。HS256是对称签名加密和解密验签用的是同一个密钥。签发Token和验证Token都在同一个服务或者同一组可信服务里逻辑最简单性能也比较好。但缺点很直接密钥必须严格保密任何拿到密钥的人都能自己伪造合法Token。所以密钥绝对不能下放到前端也不能出现在日志里。RS256是非对称签名签发方用私钥签名验签方用公钥验证。公钥可以安全地下发给任何业务服务甚至可以公开。这种模式非常适合独立的认证服务给多个业务后端签发Token的场景。即使某个业务服务的公钥泄露了别人也只能验证Token不能伪造新Token安全性更高。选择建议其实很清晰如果只是单个SpringBoot后端自己签发自己验HS256完全够用配置也省事如果已经拆分了认证中心或者要给外部系统提供接口鉴权优先用RS256。使用RS256时验签端通常只需要拿着公钥配置一个JWKS地址定期刷新公钥密钥轮换也方便很多。2.3 认证流程里JWT到底怎么流转把原理落到业务上整套认证流程其实只有四个环节。第一步用户带着账号密码访问登录接口服务端校验用户身份。第二步校验通过后服务端根据自己的密钥把userId、username、过期时间这些信息打包生成JWT返回给客户端。第三步客户端保存Token之后每次请求都在Authorization请求头里带上Bearer token。第四步服务端在拦截器或过滤器里取出Token验签、校验过期时间成功后把用户信息放到当前线程上下文再放行到业务方法。这里有一个细节值得注意JWT虽然叫“无状态认证”但验签本身需要密钥过期时间需要比对有些场景还要查黑名单所以并不是完全零存储。只是服务端不再需要为每个在线用户维护一份会话数据而已。只要把Token规范地放在请求头里任何后端服务都能通过同一套逻辑完成身份识别。3. SpringBoot整合JWT的落地步骤3.1 版本选型与依赖配置我在实际项目中用的是JJWT库也就是io.jsonwebtoken它是目前SpringBoot整合JWT最流行的选择之一。版本上我推荐锁在0.11.5这个版本的API稳定parserBuilder用起来顺手网上资料也多。0.12.0之后API有过调整比如builder()的链式调用改成了subject()直接传值新项目当然可以用新版但如果照着老代码抄作业会遇到编译不通过的尴尬情况。先看Maven依赖JJWT 0.11.5需要引入三个包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 /dependencyjjwt-api提供编译期需要用的接口jjwt-impl是运行时的默认实现jjwt-jackson负责把JSON格式的Claims和Jackson序列化框架打通。如果项目里已经用了Jackson这个包几乎不会冲突。然后在application.yml里预留配置jwt: secret: your-secret-key-please-change-me-32bytes expire-minutes: 30 header: Authorization prefix: Bearer 注意secret这一项。HS256要求密钥至少256位换算成字节数组就是32个字节所以不要随便写一个短字符串否则启动后解析Token时会直接抛异常。后面我会再说密钥管理的细节。3.2 封装JWT工具类生成、解析、校验我习惯把JWT的生成和解析统一封装成一个工具类业务代码里不直接接触JJWT的API。这样后续如果要换算法、换版本只需要改一个类。Component public class JwtUtils { Value(${jwt.secret}) private String secret; Value(${jwt.expire-minutes}) private Long expireMinutes; private SecretKey key; PostConstruct public void init() { this.key Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8)); } public String generateToken(Long userId, String username) { Date now new Date(); Date expiration new Date(now.getTime() expireMinutes * 60 * 1000); return Jwts.builder() .setSubject(String.valueOf(userId)) .claim(username, username) .setIssuedAt(now) .setExpiration(expiration) .signWith(key, SignatureAlgorithm.HS256) .compact(); } public Claims parseToken(String token) { return Jwts.parserBuilder() .setSigningKey(key) .build() .parseClaimsJws(token) .getBody(); } }生成Token的入口是登录接口校验通过后调用generateToken把userId和username放进去。这里我一般会在subject里放用户主键因为业务查询最常用到username用自定义claim承载方便日志和权限判断。解析Token时parseToken方法内部会做三件事验签、检查过期时间、解析Claims。任何一步失败都会抛出异常比如ExpiredJwtException表示过期SignatureException表示签名不合法。工具类本身不处理异常交给上层拦截器统一转成401响应。有两点经验供参考一是signWith里的SignatureAlgorithm.HS256要传不传的话JJWT会尝试从Key推断算法容易碰到签名不匹配的问题二是如果项目里需要区分Token的签发端可以在Claims里加clientType这种自定义字段解析时再去判断来源。3.3 用拦截器统一处理Token在不用Spring Security的项目里HandlerInterceptor是最轻量的鉴权方案。它只在Spring MVC层面拦截不碰Servlet过滤器链实现简单调试也很直接。先写一个UserContext用ThreadLocal保存当前登录用户信息。因为一次请求在同一个线程内执行后续Service层可以很方便地获取public class UserContext { private static final ThreadLocalLong USER_ID new ThreadLocal(); private static final ThreadLocalString USERNAME new ThreadLocal(); public static void set(Long userId, String username) { USER_ID.set(userId); USERNAME.set(username); } public static Long getUserId() { return USER_ID.get(); } public static String getUsername() { return USERNAME.get(); } public static void clear() { USER_ID.remove(); USERNAME.remove(); } }然后是核心拦截器Component public class AuthInterceptor implements HandlerInterceptor { Autowired private JwtUtils jwtUtils; Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { if (!(handler instanceof HandlerMethod)) { return true; } String authHeader request.getHeader(Authorization); if (authHeader null || !authHeader.startsWith(Bearer )) { throw new BizException(401, 未登录或Token缺失); } String token authHeader.substring(7); try { Claims claims jwtUtils.parseToken(token); UserContext.set(Long.valueOf(claims.getSubject()), claims.get(username, String.class)); return true; } catch (ExpiredJwtException e) { throw new BizException(401, 登录已过期); } catch (JwtException e) { throw new BizException(401, Token非法); } } Override public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) { UserContext.clear(); } }这里有一个容易漏的坑handler instanceof HandlerMethod判断不能省。如果不加拦截器会把静态资源、跨域预检OPTIONS请求也当成Token校验对象导致CORS预检直接失败。注册拦截器时要把登录、注册、验证码这类接口排除掉Configuration public class WebMvcConfig implements WebMvcConfigurer { Autowired private AuthInterceptor authInterceptor; Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(authInterceptor) .addPathPatterns(/**) .excludePathPatterns( /api/login, /api/register, /api/captcha ); } }最后配合一个RestControllerAdvice全局异常处理器把BizException转成JSON返回。这样做的好处是业务层不需要到处写try-catch鉴权失败统一返回401日志里也能看到清晰的原因。3.4 换成过滤器接入Spring Security如果项目用了Spring Security就不能只用MVC拦截器因为Spring Security自身的过滤器链执行顺序在MVC拦截器之前很多安全配置会把你拦截器里的认证结果覆盖掉。正确姿势是写一个JwtAuthenticationFilter把它放进去。先继承OncePerRequestFilter在doFilterInternal里解析Token并手动构建Authentication对象public class JwtAuthenticationFilter extends OncePerRequestFilter { Autowired private JwtUtils jwtUtils; Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws ServletException, IOException { String authHeader request.getHeader(Authorization); if (authHeader ! null authHeader.startsWith(Bearer )) { String token authHeader.substring(7); Claims claims jwtUtils.parseToken(token); Long userId Long.valueOf(claims.getSubject()); ListGrantedAuthority authorities new ArrayList(); Authentication authentication new UsernamePasswordAuthenticationToken(userId, null, authorities); SecurityContextHolder.getContext().setAuthentication(authentication); } chain.doFilter(request, response); } }然后在Security配置里关闭Session把自定义过滤器加到UsernamePasswordAuthenticationFilter之前Configuration EnableWebSecurity public class SecurityConfig { Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http.csrf().disable() .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS) .and() .authorizeHttpRequests() .antMatchers(/api/login, /api/register).permitAll() .anyRequest().authenticated() .and() .addFilterBefore(new JwtAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class); return http.build(); } }使用这种方案时Spring Security里原本的csrf().disable()要谨慎。如果项目是纯后端接口且使用浏览器页面关闭CSRF可能引入风险如果是纯RESTful APIToken放在Authorization头里关闭CSRF通常是合理的因为CSRF攻击主要依赖浏览器自动携带Cookie而JWT不在Cookie中。3.5 访问令牌与刷新令牌的配合JWT不能主动作废所以实际生产环境里我几乎不会只发一个长期有效的Token。更稳妥的做法是采用双Token机制AccessToken短期有效比如30分钟RefreshToken长期有效比如7天并且RefreshToken一般存放在Redis或数据库里服务端可以主动删除。登录接口正常签发一对Token。客户端在AccessToken过期后调用刷新接口带上RefreshToken换取新的AccessToken。刷新接口会先校验RefreshToken是否有效再检查Redis里的会话记录是否还在如果用户已经注销记录被删除刷新请求直接拒绝。在SpringBoot里实现起来并不复杂核心就是生成两个Token时给RefreshToken额外加一个tokenType声明刷新接口里解析时判断一下。这种方案把JWT无状态的便利和可主动注销的诉求平衡得很好推荐给需要真正上线的项目。4. 安全细节与性能优化让JWT用得更稳4.1 Token存哪里前端选择与风险JWT在客户端的存储位置直接决定安全边界。如果前端是纯浏览器页面常见选择是localStorage或HttpOnly Cookie。localStorage的最大问题是XSS。页面只要被注入一段脚本就能直接读取Token并发送到攻击者指定的地方。好处是前端取用方便请求头直接拼字符串就行。HttpOnly Cookie不能通过JavaScript读取能挡住一部分XSS窃取但会带来CSRF的顾虑因为浏览器会在跨站请求时自动带上Cookie。这时候一般要配合SameSite属性、CSRF Token一起处理。从我个人的项目经验看内部管理系统用Authorization头加localStorage是成本最低的面向外部的高安全业务尽量把刷新令牌放在HttpOnly Cookie里AccessToken放内存或短期Cookie同时把CSP、CORS收紧。App端则优先使用系统安全存储比如iOS的Keychain、Android的Keystore。还有一个很多人忽略的点不要把Token放在URL参数里。URL会出现在浏览器历史、网关日志、Nginx access log里Token一旦进了日志泄露面就失控了。我见过线上事故最终定位原因就是前端把分享链接里的Token带到了日志系统。4.2 注销与黑名单无状态认证的补偿方案JWT签发了就很难收回但身份认证场景里“注销登录”“踢人下线”“修改密码后让旧Token失效”都是刚需。这里需要一套补偿机制。最基本的是黑名单方案。用户在注销接口里把自己的Token加入Redis黑名单key可以设计成jwt:blacklist:jtivalue随意TTL设置为该Token的剩余有效时间。后续每次解析Token时先去Redis查一下有没有这个key存在就拒绝访问。因为黑名单设置了TTLToken过期后Redis里的key也会自动清理不会无限积累。另一种方法是给用户加一个版本号。登录签发Token时把当前版本号作为自定义claim写进JWT里。修改密码或被强制下线时把数据库里的版本号加一。拦截器解析完Token后再比较Claim里的版本号和数据库里的版本号不一致就拒绝。这个方案不用Redis存黑名单但每次请求多一次Redis或数据库查询适合对实时性要求没那么高的场景。如果项目里同时用Redis缓存用户信息我会把黑名单和用户缓存合并设计。比如Redis里存user:session:{userId}:{jti}登录时写入注销时删除拦截器查询是否存在。这样既支持主动注销又能精确控制每个用户的会话数量。4.3 密钥管理与多实例部署注意点JWT安全性的根基在密钥密钥一旦泄露等于登录体系全线失守。首先不要把密钥硬编码在Java类里也不要提交到Git仓库。常见做法是放到环境变量、K8s Secret或配置中心里比如jwt.secret${JWT_SECRET}这种加载方式。使用HS256时所有服务实例必须使用完全相同的密钥否则A节点签发的Token在B节点会验签失败。这个看似基础但我在多实例部署时踩过坑测试环境密钥写死了生产环境把密钥放到配置中心结果某个老节点还在用旧配置签出的Token偶尔超时排查了很久才发现是密钥不一致。更进阶的做法是使用RS256并支持密钥轮换。签发服务持有私钥业务服务持有公钥私钥定期更换公钥通过JWKS端点暴露。JWT的Header里带上kid标识验签端先根据kid找到对应公钥再验签这样新旧密钥可以并存一段时间轮换期间旧Token也不会立即失效。最后是权限隔离。开发环境、测试环境、生产环境的密钥务必不同。曾经有团队图省事所有环境共用一套密钥结果测试环境泄露的Token被直接拿来调生产接口上了不少热搜。密钥隔离在SpringBoot整合JWT里就是换一个配置项的事值得多花一分钟。5. 常见问题与排查实录5.1 高频报错速查表我把实际开发中遇到最多的问题整理成了表格按报错现象、根本原因、处理方案三列来写排查时可以直接对号入座。报错现象根本原因处理方案JWT signature does not match locally computed signature密钥不一致或Token被篡改核对签发、验签两侧的secret是否一致确认没有对Token做额外解码ExpiredJwtExceptionToken已过期前端收到401后跳登录或调用刷新接口换新TokenMalformedJwtExceptionToken格式不正确或拿到的是空字符串检查是否忘记去掉“Bearer ”前缀检查请求头是否被网关截断UnsupportedJwtExceptionHeader里的alg与验签预期不匹配检查签发和验签设置的算法是否一致检查是否混用HS256/RS256jjwt 0.12版本编译报错API有变化确认版本0.12中setSubject改为subjectparserBuilder改为parser启动时报Key长度异常HS256密钥不足32字节更换至少32字节的密钥字符串并确认UTF-8编码HandlerInterceptor不生效没有注册或排除路径写错检查WebMvcConfig是否生效检查excludePathPatterns是否匹配CORS预检请求返回401OPTIONS请求也被拦截器拦截在preHandle里对HttpMethod.OPTIONS直接放行或排除OPTIONS请求这里再补充一点排查经验报错信息里出现“JWT signature does not match”时不要只盯着Java代码看。先用工具把Token的Header和Payload解出来人工核对签名内容是否被截断、空格是否被拼进Token、换行符是否被带入。很多情况下是前端拼字符串时多了一个空格。5.2 实操心得与避坑建议做了几个项目之后我对SpringBoot整合JWT的体会越来越具体。第一不要在Payload里放敏感信息。JWT前两段只是Base64Url编码不是加密。手机号、身份证这类数据放进去等于把隐私明文暴露在Token里任何人拿到Token都可以解码读取。第二日志里不要打印完整Token。要么不打要么只打印前几位和后几位。我习惯统一封装一个脱敏组件所有打印Token的地方都走这个组件避免开发调试时手滑把Token输出到日志文件。第三拦截器解析一次Token就够了。有人会在Controller里再调用工具类解析一遍不仅重复还容易出现边界条件不一致。正确做法是把Claims塞进UserContext或Request Attribute业务方法直接取。第四如果用了Redis黑名单要注意Redis和JWT过期时间的联动。Redis的TTL最好设置为Token剩余有效时间再加一个短暂缓冲比如30秒避免刚好在边界上被误判为黑名单过期。第五不要试图把大量权限数据塞进JWT。角色、菜单、按钮级权限这种数据变化频繁且体积大更适合放在Redis或用户服务里。JWT里只放用户ID、用户名、过期时间和必要的关键声明保持Token尽可能小请求头和日志的压力也会小很多。我个人的习惯是能用HS256解决的简单项目绝对不上RS256一旦涉及多服务或者外部调用立刻切到RS256并规划好公钥分发。JWT不是越多配置越安全而是越简单、越可控越安全。把这些基础问题想在前面后面上线的时候能少熬夜很多次。