☰
基于JJWT的JWT身份认证实战:从登录签发到Token续签与安全防护
2026/10/9 8:21:01 网站建设 项目流程

后端做了几年,JWT 身份认证算是每次都会被拉出来遛一遍的核心模块。尤其是给前端 SPA 项目做登录时,Token 怎么签发、怎么验签、过期怎么续、漏洞怎么防,每一环都直接决定了接口的安全性和用户体验的顺滑度。这篇文章是我基于 JJWT 库从零搭建的一整套 JWT 认证流程总结,包含依赖配置、版本选择、工具类封装、登录接口落地、拦截器接入、Token 续签方案,以及后来踩过的各种安全漏洞和异常坑。适合正在做登录鉴权模块的后端同学,也适合刚接手认证系统、想一次性搞懂 JWT 全链路的新手。

1. 认证方案选型:JWT 到底解决了什么问题

1.1 Session 认证的典型痛点

先说为什么放着好好的 Session-Cookie 不用,非要去折腾 JWT。我在前两年维护过一个老项目,登录态全放在服务端 Session 里,所有接口靠 Cookie 里的 JSESSIONID 找对应 Session。单体部署的时候没什么大问题,后来服务一拆,问题全冒出来了:Session 要么存 Redis,要么搞粘滞会话,否则用户第一次请求打到 A 实例、第二次请求打到 B 实例就直接掉登录;前后端分离之后,跨域请求光配置 Cookie 的 SameSite、Domain 就够折腾一圈;还有 CSRF 防护,又要给请求额外塞 Token。这几个问题单独看都不致命,但合在一起,就逼着团队去换一种更契合分布式场景的认证方式。

JWT 的优势就是无状态。服务器不保存会话,登录成功后签一个 Token 发给客户端,客户端每次请求把 Token 放在 Header 里带过来,服务端只需要验签和检查过期时间,就能确认“这个人是谁”。因为不需要查存储,接口天然可以横向扩容,任意一台节点都能独立完成校验。对于现在这种微服务、网关、多端复用接口的项目结构来说,JWT 几乎是标准答案。

1.2 JWT 的数据结构与签名原理

JWT 全称是 JSON Web Token,长得像一个用点号隔开的三段字符串,格式长这样:

eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ1c2VyMTIzIiwiZXhwIjoxNzAwMDAwMDAwfQ.signature

第一段是 Header,声明签名算法,比如{"alg":"HS256","typ":"JWT"};第二段是 Payload,存放业务声明,比如用户 ID、角色、过期时间;第三段是签名,由签名算法(header + "." + payload, 密钥)算出来。三段分别做 Base64URL 编码,然后拼在一起。

这里我要特别强调一点:Payload 里的内容是明文,任何人拿到 Token 都能解出来看。它只保证“内容没被篡改”,不保证“内容不可见”。所以密码、身份证号这类敏感信息绝对不能塞进 Token,我在后面安全章节还会再提。签名的作用其实很像快递封口上的防拆贴:投递过程中只要有人动过包裹,收件人一眼就能看出封口坏了;Token 里的内容只要被改一个字符,服务端重新计算签名时就会发现对不上,直接判定无效。

1.3 为什么选 JJWT 而不是手写或自研

有人可能会说,JWT 不就是 Base64URL 加 HMAC 加时间判断吗,自己写也没几行代码。我最初也是这么想的,但真写起来才发现坑不少:Base64URL 的字符集要和标准一致,HMAC 签名要处理字节数组和字符串之间的编码,校验异常要区分过期、签名不匹配、格式损坏,还有未来的算法扩展和密钥管理。把时间花在这些造轮子上,不如直接用现成、成熟的库。

Java 生态里主流的 JWT 库有两个:JJWT(io.jsonwebtoken)和 Auth0 的 java-jwt。我选型时对比过,两者基础功能差不多,但 JJWT 的 API 语义更接近 JWT 规范本身,对 JWE、密钥构建、解析异常的分类也更细致。后来在项目里还用到它提供的Keys和Decoders工具类,确实省了不少事。所以本文所有代码都基于 JJWT 0.12.x 这套新 API 来写。

2. 依赖配置与初始化:版本坑和密钥设计

2.1 Maven 坐标与 Gradle 坐标:别再用老掉牙的 0.9.1

先上一段我项目里实际使用的 Maven 依赖。JJWT 从 0.10 开始分成jjwt-api、jjwt-impl、jjwt-jackson三个模块,其中impl和jackson是运行时实现和 JSON 序列化实现,只在runtime作用域里引入即可:

<dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt-api</artifactId> <version>0.12.5</version> </dependency> <dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt-impl</artifactId> <version>0.12.5</version> <scope>runtime</scope> </dependency> <dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt-jackson</artifactId> <version>0.12.5</version> <scope>runtime</scope> </dependency>

如果用 Gradle,对应是:

implementation 'io.jsonwebtoken:jjwt-api:0.12.5' runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.5' runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.5'

版本选择这里值得单独说几句。网上大量教程用的是 0.9.1,写法是setSubject、setExpiration、signWith(SignatureAlgorithm.HS256, key)。这套 API 太老了,新项目我不推荐再碰,因为 0.10 之后内部结构重构过,很多老方法已经被标记废弃甚至移除。我实际迁移过一次老项目,把 0.9.1 升到 0.12.x 时,所有调用点几乎都要重写一遍。如果新项目现在入场,直接拥抱 0.11 或 0.12 系列更好。

版本差异最典型的地方在解析端,我把三种版本的写法整理成了表格,方便你排查代码时对照:

版本典型解析方式说明
0.9.1Jwts.parser().setSigningKey(key).parseClaimsJws(token)老到不能再老,建议尽快升级
0.11.5Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token)稳定,网上资料也多,可用
0.12.xJwts.parser().verifyWith(key).build().parseSignedClaims(token)新 API,本文采用

另一个常见问题是依赖冲突。有些老项目里 Shiro、Spring Security 或第三方 SDK 会传递依赖进一个很老的 JJWT 版本,运行时出现NoSuchMethodError,大概率就是两个版本混用了。解决思路是用 Maven 的dependencyManagement统一固定版本,或者跑一遍mvn dependency:tree把冲突链条揪出来。

2.2 密钥配置:HS256 密钥长度不是随便写个字符串就行

密钥是 JWT 安全的基石,配置上不能偷懒。我的做法是把密钥和相关参数放在application.yml里,通过外部环境变量覆盖,而不是写死在代码里:

jwt: secret: "R2Vsb2NhdGVEZXZlbG9wTWVudG9yU3VwZXJLZXlGb3JIVFMyNTZUb2tlbjIwMjQ=" expire-minutes: 30 refresh-expire-days: 7 issuer: "my-spa-app"

配置里的secret是一段 Base64 编码后的字节串,HS256 算法要求密钥字节长度至少 32 字节(256 位)。很多新手直接配一个"123456"或"secret"当密钥,一旦使用新版 JJWT,启动或者签发 Token 时会直接抛WeakKeyException,提示 key 长度不够。

这里补充一下原因:HS256 是 HMAC-SHA256,它内部会把密钥分成块来做哈希迭代,如果密钥太短,安全性会显著下降。JJWT 在设计上直接用异常把弱密钥挡在门外,这个设计很合理,不要用suppress或换算法的方式绕过它。生成一个合格密钥的最简单办法是取一段 32 字节以上的随机字节流,再用 Base64 编码,千万别用短字符串硬凑。

2.3 配置读取与密钥对象构建

配置文件里接到的是一串文本,代码里要用 JJWT 的Decoders和Keys把它转成SecretKey对象。下面这段代码是从配置中解析密钥的常用写法:

@Component public class JwtProperties { @Value("${jwt.secret}") private String secret; @Value("${jwt.expire-minutes}") private long expireMinutes; @Value("${jwt.refresh-expire-days}") private long refreshExpireDays; // getters ... }

密钥对象本身由Keys.hmacShaKeyFor构建,这个方法会顺手校验字节长度,长度不够或格式不对直接抛异常,等于给配置加了一层启动期保护。我习惯把密钥构建放在一个只初始化一次的组件里,避免每个请求都重复解码一遍 Base64,白白浪费性能:

@Component public class SecretKeyProvider { private final SecretKey key; public SecretKeyProvider(JwtProperties props) { byte[] keyBytes = Decoders.BASE64.decode(props.getSecret()); this.key = Keys.hmacShaKeyFor(keyBytes); } public SecretKey getKey() { return key; } }

3. 工具封装:JwtService 设计与签名校验的细节

3.1 签发 Token:主体、自定义声明、过期时间怎么填

工具封装这一节,我建议不要写一个全静态方法的JwtUtil,而是封装成一个 Spring 管理的JwtService组件。理由是密钥、过期时间这些配置需要注入,静态类要么把配置写死要么每次自己读,最后很容易变成到处static乱飞的状态。

签发 Token 的核心方法如下,基于 JJWT 0.12.x 的新 API:

@Service public class JwtService { private final SecretKeyProvider keyProvider; private final JwtProperties props; public String createAccessToken(Long userId, String username, List<String> roles) { long now = System.currentTimeMillis(); Date issuedAt = new Date(now); Date expiresAt = new Date(now + props.getExpireMinutes() * 60 * 1000); return Jwts.builder() .issuer(props.getIssuer()) .subject(username) .claim("userId", userId) .claim("roles", roles) .issuedAt(issuedAt) .expiration(expiresAt) .signWith(keyProvider.getKey(), Jwts.SIG.HS256) .compact(); } }

说明几个我自己觉得值得注意的点:

  • subject我用来存用户名或用户唯一标识,标准注册声明里的sub字段就是干这个的,不要为了省事把所有信息都堆在自定义claim里。
  • claim("userId", userId)是自定义声明,存用户 ID 方便后续查库或拼权限;这里只放“非敏感但业务需要”的字段。
  • issuedAt和expiration必须带上。如果签发时忘了设置过期时间,处理方在解析时再想控制有效期就非常被动。
  • .signWith(key, Jwts.SIG.HS256)里的算法要和密钥匹配,HS256 就是对称密钥;如果项目要求非对称签名,可以用 RS256,密钥对则用KeyPairGenerator生成,这属于另一个话题,这里不展开。

3.2 解析与校验:verifyWith 和异常捕获

有签发就有解析。0.12.x 的解析器 API 和 0.11 有区别,核心是verifyWith替代了setSigningKey,parseSignedClaims替代了parseClaimsJws。解析方法我一般这样写:

public Claims parseToken(String token) { return Jwts.parser() .verifyWith(keyProvider.getKey()) .requireIssuer(props.getIssuer()) .build() .parseSignedClaims(token) .getPayload(); }

这里多做了一个requireIssuer校验,用处是防止别人拿另一个服务签发的 Token 来访问当前服务。虽然密钥不同大概率验签不过,但多个服务共用同一套密钥体系时,这个校验还是能多一层保险。

解析过程中最常见的四类异常,我直接列出来方便你对照:

异常类型触发场景业务上怎么处理
ExpiredJwtExceptionToken 已过期返回 401,提示登录态失效,触发续签或重新登录
SignatureException签名不匹配,Token 被篡改或密钥不对返回 401,并且建议记录日志,疑似恶意请求
MalformedJwtExceptionToken 格式不是三段式返回 401,多半是前端拼错了
UnsupportedJwtException加密类型或算法不受支持返回 401,检查签名算法配置

我在真实项目里见过一种很蠢的写法:在 Controller 里try-catch每个方法都解析一遍 Token,重复代码一堆。正确做法是在全局异常处理器集中处理这几种异常,比如 Spring 里继承ResponseEntityExceptionHandler,把ExpiredJwtException映射到 401,把SignatureException也映射到 401,然后统一返回一段 JSON 给前端。

3.3 别把 Claims 裸传给业务层:再封装一层登录用户上下文

Claims 本质是个 Map,业务层如果到处接收Claims,会带来很强耦合。我的做法是定义了一个LoginUser对象,把 Token 里解析出来的关键信息统一转成这一份“当前登录用户上下文”:

public class LoginUser { private Long userId; private String username; private List<String> roles; private Date expireAt; // getters/setters/构造器省略 }

然后在接口层做一次转换,比如jwtService.parseToken(token)拿到 Claims 之后,调用一个convertToLoginUser(claims)方法。这样做的好处是:如果后续要从 Redis 或数据库补充用户信息,只需改这一个转换方法;业务层永远不关心 Token 长什么样,只认LoginUser对象。

4. 登录应用实战:从验证码校验到请求拦截

4.1 登录接口完整实现:验证码、密码校验、Token 返回

很多 SPA 项目的登录页都会带图形验证码,尤其是连续输错密码之后,验证码几乎是必备步骤。这里我先说验证码的设计:生成一个随机码存在 Redis 里,设置 5 分钟过期,同时返回给前端一张 Base64 图片,登录请求带上codeId和用户输入的code。服务端先校验验证码,再校验用户名密码,最后才签发 Token。

核心逻辑可以拆成这样:

1. 根据 codeId 从 Redis 取验证码 2. 判断用户输入是否一致,不一致直接返回“验证码错误” 3. 根据 username 查询用户 4. 用 BCrypt 校验密码 5. 校验通过 -> 创建 accessToken + refreshToken -> 返回给前端

登录接口的 Controller 代码并不复杂:

@PostMapping("/api/auth/login") public ResponseEntity<LoginResponse> login(@RequestBody @Valid LoginRequest request) { // 1. 图形验证码校验 captchaService.verify(request.getCodeId(), request.getCode()); // 2. 查询并校验用户 User user = userService.findByUsername(request.getUsername()); if (user == null || !passwordEncoder.matches(request.getPassword(), user.getPassword())) { throw new AuthException("用户名或密码错误"); } // 3. 生成 Token String accessToken = jwtService.createAccessToken(user.getId(), user.getUsername(), user.getRoles()); String refreshToken = jwtService.createRefreshToken(user.getId(), user.getUsername()); return ResponseEntity.ok(new LoginResponse(accessToken, refreshToken, jwtService.getAccessTokenExpireSeconds(), convertToUserVO(user))); }

这里要提醒一个问题:验证码校验一定要在密码校验之前做,或者至少两者并行、只要有一个错误就拒绝。顺序反过来的话,攻击者可以拿脚本不断尝试用户名和密码,直到最后一步才被验证码挡住,验证码就形同虚设了。验证码更严谨的做法是校验通过后立即删除 Redis 中的记录,保证一次一用,防止重放。

BCrypt 校验这里多说一句:不要用 MD5、SHA 这类快哈希直接存密码,数据库一旦泄露,用户密码很快会被撞库工具还原。BCryptPasswordEncoder自带盐和慢哈希设计,是目前后端密码存储比较稳妥的默认选项。

4.2 请求鉴权拦截器:统一拿 Token、统一抛 401

登录接口之外的所有接口都要校验身份。项目里没有重度使用 Spring Security 的情况下,我用HandlerInterceptor实现了一套轻量鉴权,只用三块核心代码:拦截器、注册配置、全局异常映射。

先看拦截器:

@Component public class AuthInterceptor implements HandlerInterceptor { private final JwtService jwtService; @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { // 放行登录、验证码等白名单路径 if (request.getRequestURI().startsWith("/api/auth/")) { return true; } String header = request.getHeader("Authorization"); if (header == null || !header.startsWith("Bearer ")) { throw new AuthException("未携带 Token"); } try { Claims claims = jwtService.parseToken(header.substring(7)); LoginUser loginUser = jwtService.convertToLoginUser(claims); request.setAttribute("loginUser", loginUser); return true; } catch (ExpiredJwtException e) { throw new AuthException("登录已过期"); } catch (SignatureException e) { throw new AuthException("Token 不合法"); } } }

拦截器里有一个容易漏的细节:白名单路径必须显式放行。登录接口、验证码获取接口、静态资源、CORS 预检请求(OPTIONS)这几类如果不放行,就会出现“自己在这个系统里还没登录,结果连登录接口都被拦截”的哭笑不得的场景。CORS 的 OPTIONS 预检请求尤其坑,我见过不下三次线上接口跨域报错,最后查出来就是拦截器把 OPTIONS 拦掉了。

注册拦截器和路径配置也很直接:

@Configuration public class WebConfig implements WebMvcConfigurer { private final AuthInterceptor authInterceptor; @Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(authInterceptor) .addPathPatterns("/api/**") .excludePathPatterns("/api/auth/login", "/api/captcha/**"); } }

控制器里取当前登录用户时,就直接从 Request 属性拿loginUser,不用再二次解析 Token。比如:

@GetMapping("/api/user/profile") public UserVO getUserProfile(HttpServletRequest request) { LoginUser loginUser = (LoginUser) request.getAttribute("loginUser"); return userService.getProfile(loginUser.getUserId()); }

这套方案的好处是足够透明,不走 Spring Security 那套严格的过滤器链,也可以避免复杂配置带来的学习成本。如果你的项目本身已经接了 Spring Security,我更推荐把校验逻辑放在OncePerRequestFilter或SecurityFilter里,原理完全一致,只是容器不同。

4.3 前端 SPA 的 Token 存取与请求携带

后端接口完成后,前端接入同样有讲究。登录成功后,前端拿到accessToken和refreshToken,在 axios 请求拦截器里把 Token 塞进请求头:

axios.interceptors.request.use(config => { const token = localStorage.getItem('access_token'); if (token) { config.headers.Authorization = `Bearer ${token}`; } return config; });

这里要提一个安全权衡:Token 存localStorage还是内存,业界讨论很多。localStorage的优点是刷新页面 Token 不丢,实现方便,但它对 XSS 攻击就像是钱包直接放在客厅茶几上,只要脚本能执行,Token 就可能被偷走。更稳妥的做法是:短期 Token 放内存,刷新页面时用 refreshToken 重新换;或者干脆用httpOnlyCookie 存 Token,让 JS 完全没有读取能力。我在 SPA 项目里更倾向于前者,因为后端接口本身已经设计成无状态,用“内存保存 + 刷新拉取”模式能有效缩小 XSS 攻击面。

响应层面,前端拦截器还要处理 401 的情况。如果后端返回 401,说明 Access Token 过期,需要自动调刷新接口换新 Token,然后重放刚才失败的请求。这个逻辑放在统一响应拦截器里,用户基本无感知,这就是所谓的“无感刷新”。

5. Token 续签方案:滑动续签与双 Token 的取舍

5.1 Access Token 过期时间不能无限拉长

工作中我经常看到有人把 Token 过期时间设置成一小时、一天甚至一周,理由是“省得用户老要重新登录”。这个思路可以理解,但不安全。Token 一旦签发,在过期之前服务端无法主动让它失效,除非额外接黑名单或 Redis 存储。过期时间越长,Token 泄露后的有效攻击窗口就越大。所以业界普遍的做法是:Access Token 短命(15 分钟到 2 小时),Refresh Token 长命(几天到一两周),用双 Token 机制兼顾安全和体验。

5.2 双 Token 机制的落地

双 Token 的逻辑很简单:Access Token 用来访问资源,短期;Refresh Token 专门用来换新的 Access Token,长期。前端发现 Access Token 即将过期或已经过期时,拿 Refresh Token 请求一个刷新接口:

@PostMapping("/api/auth/refresh") public ResponseEntity<TokenResponse> refresh(@RequestBody RefreshRequest request) { String refreshToken = request.getRefreshToken(); // 校验 Refresh Token 本身是合法且未过期的 Claims claims = jwtService.parseRefreshToken(refreshToken); Long userId = claims.get("userId", Long.class); // 可选:比对 Redis 中保存的 refreshToken jti,防止多端互相顶替 if (!refreshTokenStore.isValid(claims.get("jti", String.class), userId)) { throw new AuthException("Refresh Token 已失效"); } // 旧 Refresh Token 立即作废,返回新 Token 对,实现轮换 refreshTokenStore.invalidate(claims.get("jti", String.class)); User user = userService.findById(userId); String newAccessToken = jwtService.createAccessToken(user.getId(), user.getUsername(), user.getRoles()); String newRefreshToken = jwtService.createRefreshToken(user.getId(), user.getUsername()); return ResponseEntity.ok(new TokenResponse(newAccessToken, newRefreshToken)); }

这里有个重要细节:Refresh Token 用一次就换一个新的。如果攻击者盗用了一个 Refresh Token,而用户正常刷新后旧的 Refresh Token 没有失效,攻击者就可以一直使用泄露的 Token;一旦实现了轮换,用户每次刷新都会作废上一次的 Refresh Token,攻击者的令牌会很快失效。我把 Refresh Token 的jti(JWT ID)也存了一份在 Redis 里,支持主动吊销和判断是否已被轮换过,这样即便用户发现问题,也可以在后端一键踢下线。

5.3 滑动续签的简单变体

如果项目规模不大,不想上双 Token,那至少可以做“滑动续签”。思路是:每次请求解析 Token 时,发现剩余有效期已经低于某个阈值(比如 5 分钟),就顺手签发一个新 Token,通过响应头X-New-Access-Token返回给前端,前端判断到有该响应头就替换本地 Token。

实现上就是在拦截器里加几行逻辑:

long remainingMillis = claims.getExpiration().getTime() - System.currentTimeMillis(); if (remainingMillis < 5 * 60 * 1000L) { String newToken = jwtService.createAccessToken(loginUser.getUserId(), loginUser.getUsername(), loginUser.getRoles()); response.setHeader("X-New-Access-Token", newToken); }

这个方案的优点是代码量小,跟现有单 Token 体系兼容;缺点是没有 Refresh Token 的“长周期凭证”,Access Token 过期时间设置非常短时,还是需要用户重新登录。所以最终取舍看你的业务复杂度:纯粹的内部管理系统用滑动续签足够,面向消费者的 C 端应用我还是建议上双 Token。

6. 常见问题与安全漏洞排查

6.1 JJWT 高频异常排查速查表

把我在项目里遇到过的问题整理成一张表,直接对着排查就行:

异常/表现根本原因解决办法
WeakKeyExceptionHS256 密钥不足 32 字节重新生成随机密钥,配置改为 Base64 串
ExpiredJwtExceptionAccess Token 过期走刷新接口或引导重新登录
SignatureExceptionToken 被篡改,或签发/校验密钥不一致检查各环境配置,确认密钥对象相同
MalformedJwtExceptionToken 不是标准三段式检查前端是否截断字符串,检查请求头拼写
NoSuchMethodErrorJJWT 版本混乱用mvn dependency:tree排查传递依赖,统一版本
偶发 401,重试又正常多环境配置不同,签发和校验用了不同密钥对比配置文件,统一环境变量

6.2 安全漏洞清单与防护建议

JWT 领域流传的那些知名漏洞,我在安全评估中也见过不少,整理成下面这份防护清单,每一条都是真实遇到过的案例或者行业里公开过的教训。

算法混淆攻击(Alg Confusion)

攻击者把 Token 头部的alg改成none,或者把RS256改成HS256,试图骗服务器“这是非对称算法,你用公钥就能验签”。JJWT 这类库默认会拒绝none算法,但如果你是自己手写校验,或者库版本过于老旧,就可能掉坑。防护上做到两点:签发和解析都显式指定允许的算法集合;绝不相信 Header 里声明的算法,一律以服务端配置为准。

密钥硬编码与弱密钥

很多项目把 JWT 密钥直接写进代码,或者配置成jwt.secret=123456。一旦代码仓库泄露,所有 Token 都能被伪造,比数据库泄露还严重。密钥必须放环境变量或密钥管理服务,长度按算法要求生成,定期轮换。轮换时要给新旧密钥一个重叠期,旧 Token 在过期前仍然能验签通过,避免一把密钥切换就大批量踢用户下线。

Payload 放敏感信息

我没少在别人系统里看到 JWT Payload 里直接放明文手机号甚至地址。记住,JWT 的 Payload 是 Base64 明文,任何人都能解码,它只有防篡改能力,没有防泄露能力。凡是涉及隐私的数据,要么不写进 Token,要么写成用户 ID 这类标识符,等后端真正需要时再查库。

过期时间缺失或设置过长

签发 Token 时不设置exp,等于给了攻击者一个永久通行证。JJWT 解析时如果 Token 没有exp并不会主动报错,这点很坑,所以工具封装时必须强制校验过期字段。设计上也不要图省事把 Access Token 设成 7 天,前面章节已经讨论过原因。

Refresh Token 无法主动吊销

无状态 Token 的最大短板就是服务端无法主动使其失效。要弥补这个短板,最简单的方法是引入一个 Redis 黑名单:用户退出登录时把 Access Token 的jti写入黑名单,过期时间设置为和 Token 本身一致;Refresh Token 则通过 Redis 存储或记录jti来实现主动吊销。否则“退出登录”就只能删除客户端本地 Token,服务器这边毫无招架之力。

日志打印完整 Token

我排查问题时会习惯性打日志,有时候顺手把请求头里的 Authorization 整个打出来,这其实是很严重的信息泄露。建议日志里只打印 Token 的前 8 位、用户 ID、过期时间这类脱敏信息,防止日志系统被拖走后把用户登录态也顺带丢了。

6.3 一次多环境密钥不一致的排查实录

最后分享一个让我记忆深刻的线上问题。某天晚上测试环境突然大面积报 401,生产环境却完全正常,刷新页面重新登录也没用。我当时第一反应是 Token 过期时间被改短了,查了配置发现没动过;又怀疑是服务器时钟漂移,NTP 同步查下来也正常。后来把测试环境打印的SignatureException堆栈捞出来,又对比了签发服务的日志,才发现测试环境的JWT_SECRET环境变量在最近一次发版时被运维覆盖成了别的值。前一台节点还在用老密钥签发,新节点用新密钥验签,两边自然对不上。这个问题解决起来不复杂,但排查过程给了我一个教训:遇到 JWT 校验失败,第一步永远先确认签发节点和校验节点用的是不是同一把密钥,尤其是在多实例部署、多环境切换的时候。

现在我的习惯是把这套认证逻辑的单元测试补得特别全:生成、解析、过期、篡改、弱密钥这五个用例,只要有人改了密钥配置或者升级了 JJWT 版本,CI 阶段第一个挂掉的往往就是测试,而不是线上接口。如果你正准备给项目加 JWT 认证,先把密钥长度、过期策略、续签方式、拦截器顺序、异常映射这五件事想清楚,后面会少踩非常多的坑。

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

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

立即咨询