1. 为什么现代后端项目绕不开JWT
做过几个前后端分离项目的朋友应该都有体会,用户登录状态的管理方式,直接决定了整个系统的架构走向。早些年用Session的时候,服务端存一份会话数据,客户端拿个SessionId来回传,单机部署倒也没啥问题。可一旦上了集群,麻烦就来了——你得考虑Session共享,要么粘性会话,要么集中存储,怎么弄都得多搭一套东西。后来大家开始转向Token方案,而JWT(JSON Web Token)算是其中最有代表性的一种实现。
JWT的核心思路很直接:服务端不存任何会话数据,把所有需要传递的信息编码成一个自包含的字符串,交给客户端保存。每次请求带上这个字符串,服务端验签、解析,就能知道是谁在访问。这种无状态特性天然适合分布式部署,也方便横向扩展。SpringBoot作为目前Java后端最主流的快速开发框架,整合JWT几乎成了标配技能。不管你是做管理后台、小程序后端还是App接口服务,这套组合都能直接套用。
这篇文章面向的是有一定SpringBoot基础、但还没完整搭过JWT验证体系的开发者。我会从一个实际项目的角度出发,把整合过程中的设计思路、关键代码、踩过的坑和排查经验都摊开来讲。你看完之后,应该能直接在自己的项目里复现一套可用的Token验证方案,而不是停留在“知道有这么个东西”的层面。
2. 整体方案设计与技术选型考量
2.1 为什么选JWT而不是Session或Opaque Token
先说说为什么在几种方案里选了JWT。Session的问题前面提过了,集群环境下需要额外维护共享存储,而且移动端对接时Cookie机制不太友好。Opaque Token(不透明令牌)是另一种常见方案,服务端生成一个随机字符串,把对应的用户信息存在Redis里,每次请求拿Token去查。这种方案的好处是可以随时吊销Token,控制力强,但代价是每次请求都要访问Redis,而且Redis挂了整个认证体系就瘫了。
JWT则是把用户信息直接编码进Token本身,服务端只需要用密钥验签就能确认合法性,不需要查任何存储。这就意味着认证过程完全不依赖外部服务,性能上有天然优势。当然JWT也不是没有缺点,最典型的就是“签发后无法主动失效”——Token还没过期,你就没法单方面让它作废。这个问题后面我会讲怎么用黑名单机制来缓解。
选型这件事没有绝对的对错,关键看场景。如果你的系统对Token吊销有强需求,比如金融类应用需要强制下线功能,那Opaque Token配合Redis可能更合适。但如果是常规的业务系统,追求轻量和无状态,JWT是更省心的选择。
2.2 Token的组成结构与签名算法选择
JWT由三部分组成,用点号分隔:Header、Payload、Signature。Header描述签名算法和类型,Payload存放实际数据(比如用户ID、角色、过期时间),Signature是对前两部分的签名,防止内容被篡改。
签名算法方面,常见的有HS256(HMAC + SHA-256)和RS256(RSA + SHA-256)。HS256是对称加密,签发和验证用同一个密钥,实现简单,适合单体应用或者内部服务之间信任度高的场景。RS256是非对称加密,私钥签发、公钥验证,适合多服务架构——比如认证服务持有私钥,其他业务服务只需要公钥就能验证Token,不用把签发密钥到处分发。
我这次的项目是个中等规模的单体应用,后续可能会拆服务但短期内不会,所以选了HS256。密钥长度方面,HS256的密钥建议至少256位,也就是32个字节。很多人随便写个"secret"就当密钥用了,这在生产环境里是非常危险的,暴力破解的成本极低。
2.3 无状态验证与刷新机制的平衡
JWT的无状态特性带来一个现实问题:Token有效期设多长?设短了,用户频繁需要重新登录,体验差;设长了,Token泄露后的风险窗口就大。常见的做法是双Token机制——Access Token有效期短(比如30分钟),Refresh Token有效期长(比如7天)。Access Token过期后,客户端用Refresh Token去换一个新的Access Token,而不需要用户重新输入账号密码。
这个方案的好处是在安全性和体验之间找到了平衡点。Refresh Token只在换Token这一个接口使用,暴露面小,而且服务端可以对Refresh Token做额外的存储和校验,实现一定程度的可控性。我在项目里就是这么做的,后面会详细讲实现。
3. 核心细节解析与实操要点
3.1 依赖引入与版本选择
SpringBoot整合JWT,最常用的库是jjwt(Java JWT)。这里有个版本坑需要特别注意:jjwt在0.10.x和0.11.x之间API变化很大,网上很多教程混着写,直接抄会编译不过。我建议用0.11.x之后的版本,模块拆分更清晰。
<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是运行时依赖。这种拆分是为了让API和实现解耦,你编译的时候只需要api包,运行时才需要具体实现。很多人只引了api包,运行时报ClassNotFoundException,就是漏了impl包。
3.2 密钥管理与配置外置
密钥绝对不能硬编码在代码里。我见过不少项目直接把密钥写在JwtUtil类里,代码一提交到仓库,密钥就泄露了。正确做法是放在配置文件里,而且生产环境的配置不要提交到代码仓库。
jwt: secret: ${JWT_SECRET:default-dev-secret-key-must-be-at-least-32-bytes-long} access-token-expire: 1800 refresh-token-expire: 604800这里用了环境变量占位符,开发环境可以用默认值,生产环境通过环境变量注入。注意默认值我特意写得很长,因为HS256要求密钥至少256位。如果你用短的密钥,jjwt会直接抛WeakKeyException。
密钥的生成可以用OpenSSL:
openssl rand -base64 48这样生成的是48字节的随机串,Base64编码后长度足够,安全性也有保障。
3.3 Token的生成与解析核心逻辑
生成Token的核心代码大概长这样:
public String generateAccessToken(Long userId, String username) { Date now = new Date(); Date expiry = new Date(now.getTime() + accessTokenExpire * 1000); return Jwts.builder() .setSubject(String.valueOf(userId)) .claim("username", username) .setIssuedAt(now) .setExpiration(expiry) .signWith(getSigningKey(), SignatureAlgorithm.HS256) .compact(); }这里有几个细节值得说。setSubject放的是用户ID,这是JWT标准里的预留字段,表示这个Token的主体。自定义信息比如用户名放在claim里。setIssuedAt和setExpiration分别设置签发时间和过期时间,这两个字段在验证时会自动检查。
解析Token的代码:
public Claims parseToken(String token) { return Jwts.parserBuilder() .setSigningKey(getSigningKey()) .build() .parseClaimsJws(token) .getBody(); }parseClaimsJws这个方法会同时做两件事:验签和检查过期时间。如果签名不对或者Token已过期,会分别抛出SignatureException和ExpiredJwtException。这两个异常需要分开处理,因为过期是正常业务场景,签名错误则可能是攻击行为。
3.4 拦截器与过滤器的选择
Token验证的切入点在SpringBoot里有两个选择:Filter和Interceptor。Filter是Servlet规范层面的,执行时机更早,能拿到原始的ServletRequest和ServletResponse。Interceptor是SpringMVC层面的,能拿到HandlerMethod,方便做基于注解的细粒度控制。
我选的是Filter,原因是认证这件事应该在请求进入SpringMVC之前就完成,不该让未认证的请求走到Controller层。而且Filter里处理跨域、设置响应头这些操作更自然。
@Component public class JwtAuthenticationFilter extends OncePerRequestFilter { @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { String token = extractToken(request); if (token != null) { try { Claims claims = jwtUtil.parseToken(token); UsernamePasswordAuthenticationToken auth = new UsernamePasswordAuthenticationToken( claims.getSubject(), null, Collections.emptyList()); SecurityContextHolder.getContext().setAuthentication(auth); } catch (ExpiredJwtException e) { // Token过期,返回特定状态码让前端去刷新 response.setStatus(401); response.setContentType("application/json;charset=UTF-8"); response.getWriter().write("{\"code\":401,\"msg\":\"token_expired\"}"); return; } catch (JwtException e) { response.setStatus(401); return; } } filterChain.doFilter(request, response); } }继承OncePerRequestFilter是为了保证一次请求只过滤一次,避免forward导致的重复执行。这个类名本身就说明了它的用途,SpringBoot里做认证过滤基本都用它。
4. 完整实操流程与关键环节实现
4.1 项目结构与模块划分
先看一下整体的包结构,这样你对各个类的职责有个直观认识:
com.example.auth ├── config │ ├── JwtProperties.java // 配置属性绑定 │ └── SecurityConfig.java // 过滤器注册与放行规则 ├── controller │ └── AuthController.java // 登录、刷新、登出接口 ├── filter │ └── JwtAuthenticationFilter.java ├── service │ └── AuthService.java // 认证业务逻辑 ├── util │ └── JwtUtil.java // Token生成与解析 └── dto ├── LoginRequest.java └── TokenResponse.java这个划分不算复杂,但职责清晰。JwtUtil只负责Token的编解码,不掺业务逻辑;AuthService处理登录验证和Token签发;Filter只做请求拦截和上下文设置。这样后续要换Token方案,改动范围可控。
4.2 登录接口与Token签发
登录接口的逻辑分几步:验证用户名密码、生成Access Token和Refresh Token、把Refresh Token存起来(用于后续校验和吊销)、返回给客户端。
public TokenResponse login(LoginRequest request) { User user = userRepository.findByUsername(request.getUsername()); if (user == null || !passwordEncoder.matches(request.getPassword(), user.getPassword())) { throw new BusinessException("用户名或密码错误"); } String accessToken = jwtUtil.generateAccessToken(user.getId(), user.getUsername()); String refreshToken = jwtUtil.generateRefreshToken(user.getId()); // Refresh Token存入Redis,key为userId,value为token redisTemplate.opsForValue().set( "refresh_token:" + user.getId(), refreshToken, refreshTokenExpire, TimeUnit.SECONDS ); return new TokenResponse(accessToken, refreshToken, accessTokenExpire); }这里把Refresh Token存Redis,是为了实现两个目的:一是可以校验客户端传来的Refresh Token是否有效(防止伪造),二是可以在用户登出或修改密码时主动删除,实现强制下线。
密码校验用了passwordEncoder.matches,说明密码在数据库里是加密存储的。这里用的是BCrypt,每次加密结果不同但都能匹配,安全性比MD5高得多。
4.3 刷新Token接口的实现
Access Token过期后,前端拿着Refresh Token来换新的Access Token:
public TokenResponse refresh(String refreshToken) { Claims claims; try { claims = jwtUtil.parseToken(refreshToken); } catch (JwtException e) { throw new BusinessException("refresh token无效"); } Long userId = Long.valueOf(claims.getSubject()); String storedToken = redisTemplate.opsForValue().get("refresh_token:" + userId); if (storedToken == null || !storedToken.equals(refreshToken)) { throw new BusinessException("refresh token已失效"); } User user = userRepository.findById(userId).orElseThrow(); String newAccessToken = jwtUtil.generateAccessToken(user.getId(), user.getUsername()); return new TokenResponse(newAccessToken, refreshToken, accessTokenExpire); }注意这里刷新时没有换新的Refresh Token,这是简化处理。更严格的做法是每次刷新都换新的Refresh Token(Refresh Token Rotation),旧的立即失效。这样即使Refresh Token泄露,攻击者用一次之后就会失效,而正常用户下次刷新时会发现Token被换过,从而察觉异常。这个机制在安全要求高的场景下建议开启。
4.4 放行规则与白名单配置
不是所有接口都需要认证,登录接口、注册接口、健康检查这些必须放行。在SecurityConfig里配置:
@Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http.csrf().disable() .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS) .and() .authorizeRequests() .antMatchers("/api/auth/login", "/api/auth/register", "/api/auth/refresh").permitAll() .antMatchers("/actuator/health").permitAll() .anyRequest().authenticated() .and() .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); return http.build(); }sessionCreationPolicy设为STATELESS是关键,告诉SpringSecurity不要创建Session,完全依赖Token。addFilterBefore把我们的JWT过滤器放在用户名密码过滤器之前,确保Token验证优先执行。
4.5 前端请求的Token携带与处理
前端每次请求需要在Header里带上Token:
Authorization: Bearer eyJhbGciOiJIUzI1NiJ9...后端提取Token的方法:
private String extractToken(HttpServletRequest request) { String header = request.getHeader("Authorization"); if (StringUtils.hasText(header) && header.startsWith("Bearer ")) { return header.substring(7); } return null; }前端收到401且响应体里code为token_expired时,自动调用刷新接口换新Token,然后重试原请求。这个逻辑建议封装在axios的响应拦截器里,对业务代码透明。
5. 常见问题与排查技巧实录
5.1 Token验证失败的典型原因速查
| 现象 | 可能原因 | 排查方向 |
|---|---|---|
| 签名验证失败 | 密钥不一致 | 检查签发和验证是否用同一密钥 |
| Token立即过期 | 服务器时间不同步 | 检查服务器NTP同步状态 |
| 解析报MalformedJwtException | Token被截断或格式错误 | 检查Header是否正确拼接Bearer前缀 |
| 刷新接口返回失效 | Redis中Token已被删除 | 检查是否有其他端登录触发了覆盖 |
| 跨域请求Token丢失 | 预检请求未放行 | 检查OPTIONS请求是否被拦截 |
5.2 时钟偏移导致的诡异过期问题
这个问题我踩过一次,印象很深。本地开发一切正常,部署到服务器后用户反馈“刚登录就提示过期”。排查了半天发现是服务器时间比标准时间慢了十几分钟,Token签发时用的服务器时间,验证时也是服务器时间,按理说应该一致。但问题出在集群里有两台机器,一台时间快一台时间慢,请求被负载均衡打到不同机器上,时间差就体现出来了。
解决办法是给jjwt设置时钟偏移容忍度:
Jwts.parserBuilder() .setSigningKey(getSigningKey()) .setAllowedClockSkewSeconds(60) .build() .parseClaimsJws(token);允许60秒的偏差,基本能覆盖大部分时间同步问题。当然根本解决还是要保证服务器NTP同步正常。
5.3 Token体积过大的隐患
JWT是把信息编码进Token本身的,如果你往claim里塞太多东西,Token会变得很长。HTTP Header虽然没有硬性大小限制,但很多网关和服务器默认限制在8KB左右。我见过有项目把用户权限列表、部门信息、角色详情全塞进Token,结果Token长度超过4KB,在某些代理环境下直接被截断。
建议claim里只放最核心的标识信息,比如用户ID和用户名。需要详细信息时,用用户ID去数据库或缓存查。Token不是用来传数据的,是用来证明身份的。
5.4 并发刷新导致的Token覆盖
双Token机制下有个隐蔽的并发问题:用户同时打开多个标签页,Access Token同时过期,多个请求同时触发刷新。第一个刷新请求换了新Token,第二个刷新请求拿着旧的Refresh Token来换,如果服务端做了Refresh Token Rotation,第二个请求就会失败,导致用户被踢下线。
解决办法有两种:一是前端做刷新请求的互斥,同一时间只允许一个刷新请求,其他请求排队等待;二是服务端对Refresh Token设置一个短暂的宽限期,旧Token在换新后的一小段时间内仍然可用。我倾向于前端互斥方案,实现简单且不降低安全性。
5.5 登出功能的正确实现
JWT本身是无状态的,服务端没法让一个还没过期的Token失效。所以登出功能需要配合黑名单或者删除Refresh Token来实现。我的做法是登出时删除Redis里的Refresh Token,这样虽然Access Token在过期前仍然有效,但用户无法再刷新,最多再过30分钟就彻底失效了。
如果业务要求立即失效,那就得维护一个Access Token黑名单,每次请求都查一下。这就牺牲了JWT无状态的优势,需要根据实际安全需求权衡。我的经验是,大部分业务场景下删除Refresh Token已经够用了,没必要为了几分钟的窗口期去搞黑名单。
6. 安全加固与生产环境注意事项
6.1 敏感信息不要放进Payload
JWT的Payload只是Base64编码,不是加密。任何人拿到Token,把中间那段Base64解码就能看到里面的内容。所以密码、手机号、身份证号这类敏感信息绝对不能放进去。我见过有项目把用户手机号放claim里,这等于把用户信息暴露给了任何能抓到Token的人。
Payload里放用户ID是最安全的做法,其他信息需要时再查。如果确实需要在Token里传敏感数据,那就得用JWE(JSON Web Encryption)对Payload加密,但这就复杂多了,一般项目用不上。
6.2 HTTPS是底线不是选项
Token在网络上传输,如果走HTTP明文,中间人直接就能抓到。有了Token就能冒充用户,所以生产环境必须上HTTPS。这不是JWT特有的要求,任何认证方案都一样。本地开发可以用HTTP,但上线前一定要确认HTTPS配置到位。
6.3 合理设置Token有效期
Access Token的有效期建议在15到30分钟之间。太短了刷新频繁,太长了泄露风险大。Refresh Token建议7天左右,同时配合滑动过期——每次刷新时延长Refresh Token的有效期,这样活跃用户不会被迫重新登录,长期不用的用户则会自然过期。
具体数值要根据业务敏感度调整。后台管理系统可以短一些,普通用户端可以长一些。没有标准答案,关键是理解每个参数背后的权衡。
6.4 防止Token被重放
JWT本身不防重放,同一个Token在有效期内可以被反复使用。对于普通业务接口这没问题,但对于支付、转账这类敏感操作,就需要额外的防护。常见做法是在Token里加一个jti(JWT ID)字段,服务端记录已使用的jti,重复使用就拒绝。或者对敏感操作要求二次验证,不单纯依赖Token。
我在项目里的做法是给敏感接口加了一层操作签名,请求参数加上时间戳和随机数,服务端校验时间戳在有效窗口内且随机数未被使用过。这样即使Token泄露,攻击者也没法直接重放敏感请求。
6.5 密钥轮换的平滑过渡
密钥用久了应该轮换,但直接换密钥会导致所有已签发的Token立即失效,用户全部被踢下线。平滑的做法是同时保留新旧两个密钥,验证时先用新密钥验,失败再用旧密钥验。等旧Token全部过期后,再移除旧密钥。jjwt支持配置多个密钥进行验证,实现起来不复杂,但需要提前规划好轮换周期。
7. 我在实际项目中的几点体会
这套JWT验证方案我在几个项目里都用过,整体跑下来很稳。最开始的时候我也踩过不少坑,比如密钥硬编码、Token塞太多信息、刷新逻辑没处理并发,都是实际出了问题才回头改的。现在回头看,JWT本身不复杂,难的是把边界情况都考虑到。
有一个建议给准备上JWT的朋友:先把双Token机制和刷新逻辑跑通,再考虑其他优化。很多人一上来就纠结黑名单、密钥轮换这些高级话题,结果基础流程都没跑顺。基础打牢了,后面的扩展都是水到渠成的事。
另外,Token的有效期和刷新策略一定要在项目初期就定好,因为这直接影响前端的请求封装和错误处理逻辑。后期再改的话,前后端都得动,成本比一开始就设计好要高得多。