做后端开发的十有八九迟早会遇到Token认证这件事。无论是前后端分离的Web应用还是纯API服务登录之后的身份识别总是绕不开的坎。Spring Boot作为目前Java后端使用率极高的框架搭配JwtToken做认证可以说是标准配置了。这篇是实战系列第八篇我准备把JWT认证从原理到落地的完整链路讲清楚包括Token怎么生成、拦截器怎么写、接口怎么保护、Postman怎么验证以及我实际项目中踩过的那些坑。这篇文章适合两类人看一类是刚接触Spring Boot不久看完很多JWT教程但总是拼不出一套完整代码的初学者另一类是已经在写接口但只用过Session或者干脆每次请求都手动校验的用户。看完你会有一套可以直接复制到项目里的认证骨架并且明白每一行代码为什么这么写。1. 为什么用JWT而不是继续用Session1.1 Session会话的痛点先说一个扎心的事实Session认证在单体应用里其实挺好用的但一旦项目开始拆分、部署多实例麻烦就来了。假设你两个后端节点挂了Nginx负载均衡用户第一次请求落到了节点ASession存在节点A的内存里第二次请求被转发到节点B节点B压根不认识这个SessionId用户就被判定为未登录。这个问题不解决就得做Session粘滞、Session共享或者引入Redis集中存储每一样都是有成本的。还有一个容易被忽略的问题Session是服务端状态每个在线用户都会在后端内存里占一块地方。用户量小的时候无所谓几万个用户同时在线光Session对象就能吃掉不少JVM内存。而且移动端场景下客户端不一定支持Cookie存储SessionId的传递方式也会变得很尴尬。前后端分离架构里前端可能是小程序、App、浏览器三种端并存Session的处理方式没法统一。1.2 JWT能带来什么JWTJSON Web Token从根本上改变了这个局面。它把用户身份信息加密签名后直接发给客户端客户端每次请求把Token带回来服务端只需要验签就能确认用户身份全程不需要保存任何会话状态。这就是无状态认证——后端没有Session没有心跳没有会话存储每个请求都像第一次见面但Token本身已经告诉服务端“我是谁、我有什么权限、什么时候过期”。我习惯用一个比喻Session像你在网吧办了张会员卡网吧电脑里记录着你的余额和上网时长换一家店就查不到JWT像一张盖了钢印的通行证能不能进会场保安验证一下钢印就知道不需要打电话问总部。正因为这个特点JWT天然适合分布式和微服务架构任何一个服务节点只要能拿到公钥或共享密钥都能独立完成用户身份验证不需要集中式会话存储。当然JWT也不是银弹。最明显的缺点是“无法主动失效”一个还没过期的Token被泄露出去在过期之前都有效。要缓解这个问题通常配合短期Token加刷新机制或者用黑名单方案后面我会单独聊。2. 工程环境与依赖准备2.1 基础环境说明本文的示例工程基于Spring Boot 2.7.x和JDK 8/11这两个组合在存量项目中覆盖面最广兼容性也最稳。如果你用的是Spring Boot 3.x核心逻辑完全一样只是javax.servlet要换成jakarta.servletjjwt低版本对JDK17的模块化限制需要留意这些差异我在踩坑部分会提。项目结构我建议按经典四层来拆Controller负责接收和响应Service处理业务逻辑Mapper或Repository管数据访问Entity放实体对象。JWT认证相关的代码单独放一个package比如com.example.jwt里面按职责分成util、interceptor、config、controller、vo这几个子包。不要把所有类都塞在一个包下否则后期维护想找都找不到。2.2 Maven依赖引入核心依赖只有两个spring-boot-starter-webWeb环境和jjwtJWT工具库。数据库这块为了控制篇幅我直接用内存Map模拟用户数据实际项目中你只需要把查询用户的部分替换成你的Mapper或Repository即可。dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdio.jsonwebtoken/groupId artifactIdjjwt/artifactId version0.9.1/version /dependency这里要特别提醒一下jjwt的版本问题。0.9.1是网上教程里最常见的版本API简洁易读但它有个坑从JDK11开始使用它需要额外引入JAXB依赖否则运行时会报ClassNotFoundException。如果你用的是JDK80.9.1直接就能跑如果JDK11以上请在pom里额外加上这一段。dependency groupIdjavax.xml.bind/groupId artifactIdjaxb-api/artifactId version2.3.1/version /dependency而jjwt 0.11.x以上版本的API则完全重写了包的路径从io.jsonwebtoken变成了io.jsonwebtoken.security构建Token的写法也从链式builder变成了KeyBuilder。网上很多帖子混着用一会儿0.9.1的代码、一会儿0.11.x的依赖最后报错都不知道去哪查。我建议你认准一个版本我这边统一用0.9.1的写法因为对初学者友好。2.3 配置文件设计JWT相关的参数不建议写死到代码里集中放在application.yml中方便不同环境切换。server: port: 8080 jwt: secret: your-secret-key-please-change-to-a-long-random-string-32bytes # 过期时间单位毫秒这里配置为2小时 expiration: 7200000 header: Authorization prefix: Bearer 关于secret参数我必须多说几句。HS256签名算法要求密钥至少256位也就是32字节。你随便写个“123456”去当密钥虽然代码可能跑得通但本质上就像用一把塑料锁锁门形同虚设。最佳实践是用密钥生成工具生成一串足够长的随机字符串长度64字节以上更稳妥并且通过环境变量注入不要直接提交到Git仓库。3. JWT工具类的核心实现3.1 Token的生成逻辑JWT本身由三段组成用点号分隔Header头部、Payload负载、Signature签名。Header声明了签名算法和Token类型Payload放业务数据比如用户名、过期时间Signature是前两段加上密钥一起做哈希计算的结果任何人对内容做一点改动签名就会对不上。先写一个工具类负责Token的创建和解析。这个类用Component交给Spring管理密钥和过期时间从配置文件读取。package com.example.jwt.util; import io.jsonwebtoken.Claims; import io.jsonwebtoken.Jwts; import io.jsonwebtoken.SignatureAlgorithm; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Component; import java.util.Date; import java.util.HashMap; import java.util.Map; Component public class JwtTokenUtil { Value(${jwt.secret}) private String secret; Value(${jwt.expiration}) private Long expiration; /** * 生成Token * param username 用户名 * return Token字符串 */ public String generateToken(String username) { MapString, Object claims new HashMap(); claims.put(username, username); claims.put(created, new Date()); return Jwts.builder() .setClaims(claims) .setSubject(username) .setIssuedAt(new Date()) .setExpiration(new Date(System.currentTimeMillis() expiration)) .signWith(SignatureAlgorithm.HS256, secret) .compact(); } }生成Token时我习惯把用户名同时放到subject和claim里subject是标准字段很多框架解析时默认从这里取用户标识而claim可以用来扩展权限角色等额外信息。过期时间用当前时间加配置文件里的duration这里所有时间单位都是毫秒别把7200000当成7200秒去算实际项目中这个单位看错导致的Bug我见过不止一次。3.2 Token的解析与校验解析Token的过程就是把客户端传来的字符串还原成Claims对象同时做签名校验和过期校验。这里需要细分异常类型因为不同异常代表的语义完全不同前端提示语也应该不同——Token过期提示“登录已过期请重新登录”签名异常提示“Token不合法”如果笼统返回“认证失败”排查问题时非常痛苦。/** * 从Token中解析Claims */ public Claims parseToken(String token) { try { return Jwts.parser() .setSigningKey(secret) .parseClaimsJws(token) .getBody(); } catch (ExpiredJwtException e) { throw new RuntimeException(Token已过期, e); } catch (SignatureException e) { throw new RuntimeException(Token签名不合法, e); } catch (MalformedJwtException e) { throw new RuntimeException(Token格式错误, e); } catch (Exception e) { throw new RuntimeException(Token解析失败, e); } } /** * 判断Token是否过期 */ public Boolean isTokenExpired(String token) { Date expiration parseToken(token).getExpiration(); return expiration.before(new Date()); } /** * 从Token中获取用户名 */ public String getUsernameFromToken(String token) { return parseToken(token).getSubject(); }这里有一个容易被绕进去的坑JJWT在解析时实际上是先验签再检查过期时间。也就是说一个已经过期的Token如果签名是合法的会抛ExpiredJwtException如果签名本身就不对则抛SignatureException。如果你的代码里把Exception一把抓然后统一返回“签名不合法”那“测试过期Token”的时候永远测不出想要的结果。我建议在拦截器层面对ExpiredJwtException做单独处理返回HTTP 401码加明确的提示信息。3.3 一个易忽略的密钥安全细节新手经常犯一个错误密钥长度不够。HS256的密钥如果少于32字节jjwt 0.9.1其实不会报错但这会导致签名空间过小存在被暴力猜测的风险。到了jjwt 0.10以上版本库本身会主动抛WeakKeyException来拒绝弱密钥所以当你升级依赖后突然遇到WeakKeyException别慌去把jwt.secret配置改成长随机字符串就行。还有一点密钥千万不要直接硬编码在Java代码里。我见过有项目把secret写在类常量里编译后class文件就能反编译出来相当于把密码贴在大门口。正确做法是环境变量注入比如在你的application.yml里写secret: ${JWT_SECRET:default-secret}部署时通过环境变量覆盖默认值本地开发用默认值生产环境强制注入。4. 认证拦截器和后端API实现4.1 为什么用Interceptor而不是Filter实现请求鉴权有两条路写一个Filter或者写一个HandlerInterceptor。很多教程推荐Filter因为它先于Spring MVC执行理论上更底层。但实际开发中我更喜欢用HandlerInterceptor原因是它能拿到HandlerMethod对象甚至可以拿到方法上的注解做细粒度的权限控制非常方便。而Filter拿不到这些信息想通过注解绕过认证就得自己反射去查Handler工作量白白增加。HandlerInterceptor有三个方法preHandle在控制器方法执行前调用返回值是false就中断请求postHandle在控制器方法返回后、视图渲染前调用afterCompletion在整个请求结束后调用。我们做Token认证只需要重写preHandle就够了。package com.example.jwt.interceptor; import com.example.jwt.util.JwtTokenUtil; import com.fasterxml.jackson.databind.ObjectMapper; import io.jsonwebtoken.ExpiredJwtException; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Component; import org.springframework.web.servlet.HandlerInterceptor; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import java.util.HashMap; import java.util.Map; Component public class AuthInterceptor implements HandlerInterceptor { Autowired private JwtTokenUtil jwtTokenUtil; Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { // 放行预检请求 if (OPTIONS.equalsIgnoreCase(request.getMethod())) { return true; } String header request.getHeader(Authorization); if (header null || !header.startsWith(Bearer )) { return writeError(response, 401, 未登录或Token缺失); } String token header.substring(7); try { String username jwtTokenUtil.getUsernameFromToken(token); // 把解析出来的用户信息放进request供后续业务使用 request.setAttribute(username, username); return true; } catch (ExpiredJwtException e) { return writeError(response, 401, 登录已过期请重新登录); } catch (Exception e) { return writeError(response, 401, Token无效); } } private boolean writeError(HttpServletResponse response, int code, String message) throws Exception { response.setStatus(code); response.setContentType(application/json;charsetUTF-8); MapString, Object result new HashMap(); result.put(code, code); result.put(message, message); response.getWriter().write(new ObjectMapper().writeValueAsString(result)); return false; } }注意几个细节。第一方法第一行放行OPTIONS请求这不是偷懒跨域请求在正式请求之前都会发一个预检请求如果你把预检请求也拦了前端连调就会看到一堆“CORS error”的报错而且跟CORS配置没关系。第二Token前缀“Bearer ”和Token之间有一个空格这个空格是标准写法截取时使用substring(7)正好跳过头和空格。第三响应体设置ContentType时别忘了加charsetUTF-8否则返回的中文乱码会让人怀疑人生。为什么要统一用“Bearer ”前缀这是RFC 6750定义的Authorization头标准格式Bearer意味着“持有者凭证”很多HTTP客户端库识别这个前缀不要自作主张不用前缀。4.2 拦截器注册与路径排除有了拦截器类还要在WebMvcConfigurer里注册它并指定哪些路径需要拦截、哪些路径放行。登录接口、静态资源、错误页肯定不能拦不然用户还没登录怎么拿到Tokenpackage com.example.jwt.config; import com.example.jwt.interceptor.AuthInterceptor; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.annotation.Configuration; import org.springframework.web.servlet.config.annotation.InterceptorRegistry; import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; Configuration public class WebMvcConfig implements WebMvcConfigurer { Autowired private AuthInterceptor authInterceptor; Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(authInterceptor) .addPathPatterns(/api/**) .excludePathPatterns(/api/auth/login); } }这里有个容易踩的坑WebMvcConfig类必须要加Configuration注解且所在包要被Spring Boot的组件扫描覆盖到。如果拦截器一点反应都没有先检查这两点八成是配置类没被扫描到。另一个坑是路径表达式写错了/api/**只匹配以/api开头的路径如果你的Controller路径不是这个前缀拦截器自然不生效。我在实际项目中习惯把有权限要求的接口统一收敛到/api/下这样一个路径规则就能覆盖全部。4.3 用户登录接口接下来写登录接口。为了不引入数据库我用一个静态Map模拟用户表实际项目中把这段逻辑替换成从Mapper查库即可。这里要强调一下密码校验的方式真实项目中密码不能存明文要用BCrypt这类算法做哈希登录时比对哈希值而不是比对明文。这已经是行业共识不用再争论。package com.example.jwt.controller; import com.example.jwt.util.JwtTokenUtil; import com.example.jwt.vo.LoginRequest; import com.example.jwt.vo.LoginResponse; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.web.bind.annotation.*; import java.util.HashMap; import java.util.Map; RestController RequestMapping(/api/auth) public class AuthController { Autowired private JwtTokenUtil jwtTokenUtil; // 模拟用户数据实际项目请换成数据库查询 private static final MapString, String USER_DB new HashMap(); static { USER_DB.put(admin, 123456); USER_DB.put(user, 654321); } PostMapping(/login) public LoginResponse login(RequestBody LoginRequest request) { String password USER_DB.get(request.getUsername()); if (password null || !password.equals(request.getPassword())) { throw new RuntimeException(用户名或密码错误); } String token jwtTokenUtil.generateToken(request.getUsername()); return new LoginResponse(token, Bearer, 7200000L); } }登录接口的响应体不要只返回一个裸Token字符串我强烈建议封装成一个对象包含token、tokenType、expiresIn三个字段。这样前端拿到后既能直接使用也能知道Token什么时候过期方便提前做续期。public class LoginResponse { private String token; private String tokenType; private Long expiresIn; public LoginResponse(String token, String tokenType, Long expiresIn) { this.token token; this.tokenType tokenType; this.expiresIn expiresIn; } // getter/setter省略 }可能有同学会问Controller里直接抛RuntimeException错误信息会以默认方式返回前端拿到的不是JSON格式怎么办这个问题问得好项目里应该写一个全局异常处理器用RestControllerAdvice统一捕获异常把错误信息包装成统一的JSON结构。为了控制篇幅我这里就不贴完整代码了但这是每个正规项目必备的类建议你自己补上。4.4 受保护的业务接口写一个示例接口模拟“获取当前用户信息”。这个接口路径在拦截器覆盖的范围内所以必须携带有效的Token才能访问。package com.example.jwt.controller; import org.springframework.web.bind.annotation.*; import javax.servlet.http.HttpServletRequest; import java.util.HashMap; import java.util.Map; RestController RequestMapping(/api/user) public class UserController { GetMapping(/info) public MapString, Object getUserInfo(HttpServletRequest request) { String username (String) request.getAttribute(username); MapString, Object result new HashMap(); result.put(username, username); result.put(nickname, 昵称- username); result.put(roles, new String[]{ROLE_USER}); return result; } }拦截器里request.setAttribute(username, username)这段代码的操作意图就是让身份信息在同一个请求周期内传递下去Controller层不需要再解析Token直接拿request里的属性即可。这里要注意一个使用习惯不要用ThreadLocal跨线程传递用户信息因为一旦用了线程池ThreadLocal的变量可能被下一个任务读到产生串号即便用了也要记得在finally里remove。4.5 关于Spring Security的一点说明如果你在搜索引擎搜JWT和Sprign Boot大概率会看到一大堆基于Spring Security JWT的教程。Spring Security本身是个好东西但它的过滤器链机制、用户详情服务、授权管理器这些概念对新手很不友好初学者照着配置很容易糊里糊涂。本文这个方案用一个拦截器实现了核心认证逻辑没有引入Security并不代表Security不行而是为了让读者先搞懂JWT本身的工作原理。如果你的项目已经集成了Spring Security这时候不要硬删掉Security更优雅的做法是写一个OncePerRequestFilter在Security的过滤器链中提前解析Token并把它塞进SecurityContext然后让Security继续管理后续授权。这部分内容比较多等后面专题再展开聊。5. 用Postman完整走一遍验证流程5.1 正常流程验证启动项目打开Postman第一步先请求登录接口。请求方式POST请求URLhttp://localhost:8080/api/auth/login请求体JSON格式{username:admin,password:123456}正常响应应该是这样的{ token: eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJhZG1pbiIsImNyZWF0ZWQiOjE3MDk1MDAwMDAwMDAsImV4cCI6MTcwOTUwNzIwMDAwMH0.signature, tokenType: Bearer, expiresIn: 7200000 }第二步访问受保护接口。在Authorization页签选择“Bearer Token”类型把刚才返回的token粘进去再访问http://localhost:8080/api/user/info应当返回用户信息JSON。这个流程看着简单实际联调时前端经常踩一个坑把token复制出来之后不小心复制到了换行符或者多了一个空格请求发出去就一直401。排查这种问题建议在Chrome开发者工具里看请求头的原始值大部分时候一眼就能看出问题。5.2 异常流程验证按下面这个表把各种异常情况都测一遍基本可以覆盖上线后的主要认证问题。场景请求方式预期结果不用Token访问受保护接口GET /api/user/info401提示“未登录或Token缺失”使用过期TokenGET /api/user/info401提示“登录已过期请重新登录”篡改Token内容GET /api/user/info401提示“Token无效”请求带错误前缀Authorization: admin token401提示“未登录或Token缺失”登录接口本身POST /api/auth/login200不受拦截器限制我建议你把过期时间的配置临时改成比如36000001小时专门用来测“Token过期”这个场景不然你刚生成的Token没过期测试时永远走不到过期分支。这也是一个实用的调试技巧临时把配置改小快速触发边界条件。6. 常见问题与排查技巧实录6.1 报错排障速查表实际开发中我整理的这些问题是出现频率最高的建议收藏。症状根本原因解决办法拦截器完全不生效WebMvcConfig类没加Configuration或包路径没被扫描确认配置类上有Configuration注解请求返回401但没有响应体可能误引入了Spring SecuritySecurity默认拦截了请求检查依赖里是否有security相关starter拦截器生效但写中文乱码响应ContentType没设置charsetUTF-8统一使用application/json;charsetUTF-8Token拿到手却解析不出用户名generateToken时没设置subject直接往claims里塞username同时设置setSubject和claim解析时用getSubject前端跨域请求一直失败预检OPTIONS请求被拦截器拦截在preHandle里放行OPTIONS请求前端读不到自定义响应头CORS配置里的exposedHeaders没设置在CorsConfiguration中配置exposedHeaders升级jjwt后报WeakKeyException密钥长度不足256位配置至少32字节的随机密钥返回401时前端拿到的code字段是0全局异常处理器吞掉了异常检查RestControllerAdvice的异常处理方法优先级6.2 多环境与密钥管理说完排障再补充一个容易被忽视的运维点。jwt.secret这种敏感配置在开发、测试、生产环境的取值不能一致。开发环境大家都用同一个默认值问题不大可一旦上了生产环境还沿用开发环境的密钥那写代码的同事就都能签发线上Token了这等于把后门敞开着。我建议这样处理本地application.yml里留一个默认值方便启动生产环境在部署脚本中指定环境变量JWT_SECRETSpring Boot的配置规则是环境变量优先级高于配置文件所以无需改动代码就能覆盖。类似这样jwt: secret: ${JWT_SECRET:dev-only-secret-change-me}配置中心比如Nacos的环境隔离功能也值得一用不同namespace的配置天然隔离再配合环境变量注入密钥管理基本就稳了。6.3 无状态Token的登出与续签方案前面说了JWT没法主动失效这里给两个工程上常用的思路。第一个是Redis黑名单方案。用户登出时把该用户的Token唯一标识比如jti字段一个Token生成时生成的唯一ID写入Redis过期时间与Token剩余有效期一致。拦截器解析Token后先查一下Redis里有没有这个黑名单存在就拒绝访问。代价是存了状态“无状态”被打了一点折扣但为了登出功能这个折扣值得付。第二个是短期Token加RefreshToken方案。AccessToken有效期设置得短一些比如30分钟客户端在Token快过期的时候拿RefreshToken去换新的AccessToken。这个方案更贴近真实生产环境网上也有很多现成的实现思路。我的建议是小型项目用方案一先顶住体量上来后再平滑过渡到方案二。6.4 一些小技巧日志方面不要在日志里打印完整的Token字符串Token就是一把临时钥匙被日志采集系统收录后就是一个长期安全隐患。真要排查问题只打印Token的前几位和后几位就够了。拦截器写JSON响应时每次用ObjectMapper写一遍挺啰嗦可以抽一个工具方法或者直接用Spring的ResponseBodyEmitter之类的方式。不过最简单的方法还是像我上面那样在工具方法里完成写JSON这步所有拦截器共用。支付宝和微信支付的开放平台也会用到JWT它们的JWT库版本和写法可能和我们的不一样但原理完全相同。理解了本文这套体系以后对接外部系统的签名验证也基本能做到心里有底。最后再分享一点实战体会做完这套JWT认证我的体会是认证方案没有绝对的最优只有适不适合当前团队。Interceptor方案轻量、直观、容易排查适合中小型项目Spring Security方案功能全、扩展强适合需要复杂权限模型的团队。但无论选哪种建议把Token的生成、解析、校验集中在一个工具类里不要在Controller里散落乱写这样将来替换方案时成本最低。如果你在实践过程中遇到问题优先按第6节的速查表对照一遍八成问题都能定位。网上关于JJWT版本差异导致的编译错误也特别多建议锁定一个版本后不要频繁升级等真正理解了升级带来的收益再动。这篇先聊到这儿很多内容其实值得展开成独立专题咱们后面接着聊。