☰
SpringBoot整合JWT:从原理到实战,搞定无状态登录认证
2026/10/11 16:56:59 网站建设 项目流程

如果你做过服务端接口开发,一定绕不过一个问题:登录之后的用户身份怎么识别。我在前后端分离的项目里反复对比过几种方案,最后用下来最顺手的就是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、Signature

2.1 三段式结构的拆解

JWT字符串看起来是一串用点号分隔的三段内容,大概长这样:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c

第一部分是Header,内容通常包含两个字段:alg表示签名算法,比如HS256;typ固定为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> <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-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 ThreadLocal<Long> USER_ID = new ThreadLocal<>(); private static final ThreadLocal<String> 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()); List<GrantedAuthority> 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 API,Token放在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:<jti>,value随意,TTL设置为该Token的剩余有效时间。后续每次解析Token时,先去Redis查一下有没有这个key,存在就拒绝访问。因为黑名单设置了TTL,Token过期后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后跳登录,或调用刷新接口换新Token
MalformedJwtExceptionToken格式不正确,或拿到的是空字符串检查是否忘记去掉“Bearer ”前缀,检查请求头是否被网关截断
UnsupportedJwtExceptionHeader里的alg与验签预期不匹配检查签发和验签设置的算法是否一致,检查是否混用HS256/RS256
jjwt 0.12版本编译报错API有变化确认版本,0.12中setSubject改为subject,parserBuilder改为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不是越多配置越安全,而是越简单、越可控越安全。把这些基础问题想在前面,后面上线的时候能少熬夜很多次。

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

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

立即咨询