Spring Boot JWT认证实战:从原理到拦截器落地
2026/9/13 14:40:16 网站建设 项目流程

做后端开发的,十有八九迟早会遇到Token认证这件事。无论是前后端分离的Web应用,还是纯API服务,登录之后的身份识别总是绕不开的坎。Spring Boot作为目前Java后端使用率极高的框架,搭配JwtToken做认证可以说是标准配置了。这篇是实战系列第八篇,我准备把JWT认证从原理到落地的完整链路讲清楚,包括Token怎么生成、拦截器怎么写、接口怎么保护、Postman怎么验证,以及我实际项目中踩过的那些坑。

这篇文章适合两类人看:一类是刚接触Spring Boot不久,看完很多JWT教程但总是拼不出一套完整代码的初学者;另一类是已经在写接口,但只用过Session或者干脆每次请求都手动校验的用户。看完你会有一套可以直接复制到项目里的认证骨架,并且明白每一行代码为什么这么写。

1. 为什么用JWT,而不是继续用Session

1.1 Session会话的痛点

先说一个扎心的事实:Session认证在单体应用里其实挺好用的,但一旦项目开始拆分、部署多实例,麻烦就来了。假设你两个后端节点挂了Nginx负载均衡,用户第一次请求落到了节点A,Session存在节点A的内存里,第二次请求被转发到节点B,节点B压根不认识这个SessionId,用户就被判定为未登录。这个问题不解决,就得做Session粘滞、Session共享或者引入Redis集中存储,每一样都是有成本的。

还有一个容易被忽略的问题:Session是服务端状态,每个在线用户都会在后端内存里占一块地方。用户量小的时候无所谓,几万个用户同时在线,光Session对象就能吃掉不少JVM内存。而且移动端场景下,客户端不一定支持Cookie存储,SessionId的传递方式也会变得很尴尬。前后端分离架构里,前端可能是小程序、App、浏览器三种端并存,Session的处理方式没法统一。

1.2 JWT能带来什么

JWT(JSON 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.servlet,jjwt低版本对JDK17的模块化限制需要留意,这些差异我在踩坑部分会提。

项目结构我建议按经典四层来拆:Controller负责接收和响应,Service处理业务逻辑,Mapper或Repository管数据访问,Entity放实体对象。JWT认证相关的代码单独放一个package,比如com.example.jwt,里面按职责分成util、interceptor、config、controller、vo这几个子包。不要把所有类都塞在一个包下,否则后期维护想找都找不到。

2.2 Maven依赖引入

核心依赖只有两个:spring-boot-starter-web(Web环境)和jjwt(JWT工具库)。数据库这块为了控制篇幅,我直接用内存Map模拟用户数据,实际项目中你只需要把查询用户的部分替换成你的Mapper或Repository即可。

<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt</artifactId> <version>0.9.1</version> </dependency>

这里要特别提醒一下jjwt的版本问题。0.9.1是网上教程里最常见的版本,API简洁易读,但它有个坑:从JDK11开始,使用它需要额外引入JAXB依赖,否则运行时会报ClassNotFoundException。如果你用的是JDK8,0.9.1直接就能跑;如果JDK11以上,请在pom里额外加上这一段。

<dependency> <groupId>javax.xml.bind</groupId> <artifactId>jaxb-api</artifactId> <version>2.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) { Map<String, 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;charset=UTF-8"); Map<String, 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时别忘了加charset=UTF-8,否则返回的中文乱码会让人怀疑人生。

为什么要统一用“Bearer ”前缀?这是RFC 6750定义的Authorization头标准格式,Bearer意味着“持有者凭证”,很多HTTP客户端库识别这个前缀,不要自作主张不用前缀。

4.2 拦截器注册与路径排除

有了拦截器类,还要在WebMvcConfigurer里注册它,并指定哪些路径需要拦截、哪些路径放行。登录接口、静态资源、错误页肯定不能拦,不然用户还没登录怎么拿到Token?

package 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 Map<String, 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 Map<String, Object> getUserInfo(HttpServletRequest request) { String username = (String) request.getAttribute("username"); Map<String, 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
  • 请求URL:http://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,不受拦截器限制

我建议你把过期时间的配置临时改成比如3600000(1小时),专门用来测“Token过期”这个场景,不然你刚生成的Token没过期,测试时永远走不到过期分支。这也是一个实用的调试技巧:临时把配置改小,快速触发边界条件。

6. 常见问题与排查技巧实录

6.1 报错排障速查表

实际开发中,我整理的这些问题是出现频率最高的,建议收藏。

症状根本原因解决办法
拦截器完全不生效WebMvcConfig类没加@Configuration,或包路径没被扫描确认配置类上有@Configuration注解
请求返回401但没有响应体可能误引入了Spring Security,Security默认拦截了请求检查依赖里是否有security相关starter
拦截器生效但写中文乱码响应ContentType没设置charset=UTF-8统一使用application/json;charset=UTF-8
Token拿到手却解析不出用户名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_SECRET,Spring 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版本差异导致的编译错误也特别多,建议锁定一个版本后不要频繁升级,等真正理解了升级带来的收益再动。这篇先聊到这儿,很多内容其实值得展开成独立专题,咱们后面接着聊。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询