news 2026/10/10 19:40:54

JWT认证原理与Spring Boot实践:从无状态Token到安全续签方案

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
JWT认证原理与Spring Boot实践:从无状态Token到安全续签方案

做了这么多年后端,API接口被人扒得一干二净的经历真不少。很多项目一开始图省事,把用户身份直接塞进Cookie里,前后端分离一搞,跨域、CSRF、服务端Session存哪这些问题全冒出来了。后来普遍转向JWT(JSON Web Token),把认证状态做成一段自包含的签名Token,服务端不用存Session,接口一验签就知道是谁,这套思路现在基本是API保护的主流方案。

这篇内容适合正在做前后端分离项目、微服务架构、或者想把自己写的接口从“裸奔”状态拉回来的开发者看。我会把JWT从原理到落地拆开讲,结合真实项目里常见的坑,比如401报错、Token过期、续签失败、算法混淆漏洞,给你一套可以直接抄作业的实践方案。你要是已经在用JWT了,重点看第4章和第5章,那里有不少常规文档不会写的细节。

1. 为什么用JWT:无状态认证的取舍

1.1 Session认证的痛点

很多老项目用的是Session-Cookie模式:用户登录成功后,服务器生成一个Session ID,存在服务端内存或Redis里,同时通过Cookie下发给浏览器。后续每次请求带上Cookie,服务器比对Session ID,命中就算登录。这套机制在单体应用时代问题不大,但到了前后端分离和微服务场景就开始别扭了。

首先是跨域问题。前端部署在另一个域名或端口,请求后端接口时Cookie的跨域携带要额外配置withCredentials,后端还得开CORS白名单,稍不注意就会被浏览器拦截。其次是存储压力,多实例部署时Session默认存在单机内存里,负载均衡一转发,请求落到别的机器上就找不到Session了,必须引入Redis统一存储,这本身又是一套运维成本。再加上移动端App没有Cookie概念,要手动维护Session ID,用起来非常别扭。

那有人会说,我把Session做成Token不就行了?客户端每次带上,服务端查Redis校验。这确实能解决跨域和存储分布问题,但每次请求都查一次Redis,链路多了两次网络开销,而且Redis挂了整个认证体系就瘫了。JWT的不同在于,它把用户信息、过期时间、签名全部塞进Token本身,服务端只需要验签,不需要查存储,这才是真正意义上的无状态。

1.2 JWT的核心思路与适用边界

JWT的思路很直白:用户登录成功后,服务端把用户ID、角色、过期时间等信息写成JSON,经过Base64转码后拼在一起,再用密钥签名。客户端拿到这个Token后存起来,后续请求放在Authorization: Bearer <token>头里。服务端收到请求后验签,签名合法就信任里面的payload,直接拿到用户身份,不用再去查数据库。

这种设计的最大优势是横向扩展极方便。你有十台后端实例,大家共用同一个签名密钥,任何一台机器都能独立验签,不需要共享Session存储,新加机器也不用迁移数据。另外它天然适合跨系统传递身份,比如网关验签后把用户信息加密塞进JWT,下游服务验签后直接使用,不用逐层调用户服务。

但它不是万能的。JWT一旦签发,在过期之前理论上一直有效,服务端想主动踢人下线是做不到的,除非引入额外黑名单机制。如果你有封号、强制下线、实时统计在线人数这类需求,纯JWT方案会非常难受。所以选不选它,先看业务对“立即可撤销”的要求高不高。

1.3 什么场景不适合JWT

我见过不少团队盲目追新,把内部管理系统的登录态也改成JWT,结果被封号功能折腾得够呛。如果一个后台系统管理员需要实时禁用某个被员工身份,JWT的“不可撤销”特性就会被无限放大。当然可以加Redis黑名单,但那就把无状态优势抵消了一半,还不如老老实实存Session。

适合JWT的典型场景有几类:对外提供的API网关(比如聚合支付、开放平台)、前后端分离的单页应用、微服务之间的服务间认证(配合网关或内部服务调用)、临时授权链接(比如邮箱验证、免登录分享)。这几类场景的共同特点是客户端和服务器完全分离,对Token主动失效要求不高,或者可以用业务手段规避(比如客户端检测到401就跳登录页)。

2. JWT结构拆解与签名原理

2.1 Header、Payload、Signature三个部分

一个标准的JWT长什么样?直接看例子:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9 .eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IlpoYW5nU2FuIiwiaWF0IjoxNzE4MDAwMDAwfQ .SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c

这段字符串用点号拆成三段,分别是Header、Payload、Signature。Header里通常声明签名算法(alg)和Token类型(typ)。Payload是业务数据,我叫它“声明”,这里面可以为任意JSON字段加进去,但切记只能放非敏感信息,任何密码、手机号、身份证号都不要往里塞。Signature就是用密钥对前两段做的签名。

Payload里最常用的是sub(用户标识)、iat(签发时间)、exp(过期时间)。还有一个aud和iss,分别代表受众和签发方,做多系统间认证时很有用,比如网关签发的Token里声明aud是订单服务,订单服务验签时先看受众是不是自己,防止一个Token在系统间乱用。

2.2 HS256与RS256怎么选

算法选择是JWT最容易踩坑的地方。HS256是对称哈希算法,加密和解密用同一个密钥,实现简单、性能好,适合单服务或完全信任的内部环境。但它有个天然弱点:谁能拿到密钥,谁就能伪造Token,所以密钥绝对不能下发到客户端,只有后端知道。

RS256是非对称算法,私钥签名、公钥验签。适合开放API或第三方对接场景,你可以把公钥直接放到网上去,客户端用公钥验签,却没有私钥伪造Token。很多第三方登录服务(比如OAuth2的ID Token)都用RS256,道理就在这里。

维度HS256RS256
密钥类型对称密钥(同一个)私钥签名/公钥验签
验证速度快较慢(非对称计算)
适用场景单一后端服务、完全信任内部开放平台、多服务解耦、第三方接入
密钥泄露影响可伪造任意Token公钥泄露不影响,私钥泄露才危险
密钥管理全体后端共享一个Key私钥集中管理,公钥随分发

如果你用的是Spring Security OAuth2或OIDC,默认很多实现走RS256,因为下游资源服务器只需要公钥验签,不需要和认证服共享秘密。小型项目图省事用HS256完全可以,但密钥不要写死在代码里,要从环境变量或配置中心读取。

2.3 签名校验的底层逻辑与常见误区

很多人以为JWT只要解析出来就算验证通过,这是大忌。JWT的安全核心是验签,不能只解码 。解码任何人都会,用Base64转一下就行,但签名校验需要正确的密钥。服务端收到Token后,要用和签发时相同的算法、相同密钥重新计算签名,和Token自带的签名比对,一致才说明内容没被篡改。

这里有个非常经典的攻击方式叫“算法混淆”。攻击者把Header里的alg改成none,或者从HS256改成RS256,如果后端验签时把算法也完全信任Header里的声明,就可能被绕过。正确的做法是:验签时固定算法白名单,比如代码里只允许HS256,其他一律拒绝。使用jwt库时,也要关闭allowInsecureAlgorithms这类宽松选项。我在实际项目里加过一道保险:签发时在Header固定一个自己的版本号,验签时先检查版本号,不符合直接拒绝,防止库的默认行为被带偏。

3. 落地实现:Spring Boot + JWT完整流程

3.1 依赖引入与工具类封装

用Java做示例,目前生态环境比较好的是jjwt(Java JWT)和spring-security-jwt。我比较喜欢io.jsonwebtoken:jjwt,它API简洁,版本选0.12.x。Maven引入这几个依赖:

<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>

工具类封装的时候,至少要做这四件事:生成Token、解析Token、校验签名、从Token里取用户标识。注意生成时用Jwts.builder(),设置subject为用户ID,claim可以放角色列表,设置issuedAt和expiration,最后用密钥签名。解析时用Jwts.parser().verifyWith(SecretKey).build().parseSignedClaims(token)拿到Claims。

有一个细节容易被忽略:首页生成密钥时,HS256要求密钥长度至少256位(32字节)。很多人都用"my-secret"这种短字符串,运行时会直接抛WeakKeyException。我一般会用Base64编码后的64字符随机字符串,代码从环境变量读取,再解码成SecretKey。

3.2 登录接口:校验密码、签发Token

登录流程拿到用户名密码后,先查用户,再用BCryptPasswordEncoder比对密码。密码匹配后组装用户信息,签发Token。下面这个简化版本能说明核心流程:

@PostMapping("/login") public ResponseEntity<?> login(@RequestBody LoginRequest request) { // 1. 查询用户 User user = userService.findByUsername(request.getUsername()); if (user == null || !passwordEncoder.matches(request.getPassword(), user.getPassword())) { return ResponseEntity.status(401).body("用户名或密码错误"); } // 2. 生成JWT,有效期24小时 Date now = new Date(); Date exp = new Date(now.getTime() + 24 * 60 * 60 * 1000); String token = Jwts.builder() .subject(user.getId().toString()) .claim("roles", user.getRoles()) .issuedAt(now) .expiration(exp) .signWith(secretKey) .compact(); // 3. 返回Token和过期时间 return ResponseEntity.ok(new LoginResponse(token, exp, user.getUsername())); }

这里有几个生产环境必须处理的问题:第一,登录接口要加验证码或者登录失败次数限制,防暴力破解。第二,不要把密码错误和用户不存在返回成两种不同的消息,会暴露用户是否注册。第三,返回Token时顺便返回过期时间,前端可以提前续期或跳登录,避免必然的401体验。

3.3 拦截器/过滤器校验Token

有了Token,需要一个统一入口来校验。用Spring MVC的话,实现HandlerInterceptor最直接,在preHandle里取Header、解析Token、把用户信息放进``ThreadLocal或RequestContext`,然后放行。下面是一个基础版本:

@Component public class JwtInterceptor implements HandlerInterceptor { @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 ")) { throw new UnauthorizedException("缺少认证Token"); } String token = header.substring(7); try { Claims claims = Jwts.parser() .verifyWith(secretKey) .build() .parseSignedClaims(token) .getPayload(); request.setAttribute("userId", claims.getSubject()); request.setAttribute("roles", claims.get("roles")); return true; } catch (JwtException | IllegalArgumentException e) { throw new UnauthorizedException("Token校验失败"); } } }

注册拦截器时要设置白名单。登录接口、注册接口、健康检查、静态资源这些不需要认证的路径,必须在excludePathPatterns里配置。我建议把白名单单独维护一个常量列表,并写明每个路径为什么不需要鉴权,防止后人随便加接口忘了加认证。

3.4 权限标注与接口保护

只做到“是谁”还不够,还要做到“能干什么”。最简单的是在拦截器里解析出角色列表后配合自定义注解使用。例如:

@Target(ElementType.METHOD) @Retention(RetentionPolicy.RUNTIME) public @interface RequireRole { String[] value(); }

然后在Controller方法上标@RequireRole({"ADMIN"}),拦截器里拿到请求的HandlerMethod,检查方法上有没有这个注解,有就比对当前用户的角色。角色不具备就返回403。这套自定义方案够用,但如果你用的是Spring Security,直接结合@PreAuthorize会更规范,用JWT时只需要把当前用户的权限放进Authentication里。

权限这块有一个经验:不要在Controller里写一堆if (user.isAdmin())判断,尽量通过注解或职责链统一处理。业务代码里到处散落权限判断,后面做权限审计时你会想哭。

4. Token续签、刷新与失效策略

4.1 为什么Token不能随便续签

JWT的过期时间是签死在payload里的,过期后服务端验签一定失败。很多新手想当然地觉得“过期了我再签一个不就行了”?这不叫续签,这叫偷偷把过期Token延长。一旦惯例接口能自动给过期Token续期,攻击者拿到一个泄露的Token就可以永远续下去,过期机制形同虚设。

正确的思路是引入刷新令牌(Refresh Token)。访问令牌(Access Token)有效期短,比如15分钟到2小时;刷新令牌有效期长,比如7天或30天。Access Token过期后,客户端用Refresh Token调一个专门的刷新接口换新的Access Token。刷新令牌只能用来换Access Token,不能当作业务身份使用。这样即使Access Token泄露,攻击者能用的时间窗口也很短;Refresh Token泄露虽然风险大,但可以再配合设备白名单、旋转刷新令牌来缓解。

4.2 刷新令牌方案

最简单的实现是双Token方案:登录时同时生成Access Token和Refresh Token。Refresh Token可以也做成JWT,但一般要存一份在Redis里,键是refresh:{userId}:{uuid},值是当前有效的Token或随机字符串。刷新接口拿着Refresh Token来换,先验签名,再比对Redis里的值,一致才签发新的Access Token,同时销毁旧Refresh Token,签一个新的返回给客户端。

这个方案的好处是可控性回来了。服务端知道谁有有效刷新令牌,想吊销用户登录状态时,直接把Redis里对应的键删掉就行。虽然Access Token还不能立刻失效,但最多坚持到它的短过期时间,基本可以接受。

实际操作时有几个细节要注意:第一,刷新接口和登录接口一样要防暴力,Refresh Token不存在的话不要返回太明确的提示;第二,刷新后的Token一定要重新生成jti或随机串,防止旧Refresh Token被反复重放;第三,移动端和Web端尽量使用不同的Refresh Token队列,方便做“踢线”和“多端管理”。

4.3 服务端失效与黑名单思路

如果业务确实需要让Access Token立刻失效,常见做法是维护一个黑名单。Token里放一个唯一的jti(JWT ID),每次请求验签后,把jti放到Redis黑名单里,黑名单里有一律拒绝。Access Token过期时间短,黑名单可以顺带设置TTL等于Token剩余有效期,防止Redis里塞满过期key。

另一个思路是引入“签发生态”。用户信息变更时(比如改密码、封号),在Redis里维护一个用户级别的版本号或封禁标记,签发Token时把版本号写进payload,验签时比对Redis中的当前版本。版本不一致就拒绝。这个方案比逐条拉黑jti更精准,适合“踢某个用户全部设备”的场景。代价是每次请求都查一次Redis,和无状态初衷有冲突,需要权衡。

5. 常见问题与漏洞排查实录

5.1 常见JWT漏洞:算法混淆、密钥泄露

JWT的漏洞总结下来,最常见的就是算法混淆攻击、弱密钥、敏感信息泄露、以及无限期Token。算法混淆前面已经提过,防御手段就是服务端提供固定算法白名单。弱密钥问题在HS256中尤其突出,网上有字典库可以直接爆破。我见过有项目用"jwt-secret"当密钥的,上线第一天就被扫描器打穿了。生成密钥用java.security.SecureRandom,或者用工具生成至少32字节的随机值并存到密钥管理服务。

还有一个经常被忽略的问题是签名算法被降级。比如签发时用RS256,验签时如果允许alg=HS256,攻击者拿公钥作为HS256的密钥来伪造签名,就能轻松通过校验。这是2015年就公布的老漏洞了,但现在很多老代码还在踩。我用jjwt时会在工具类里写死验签算法,甚至封一层自定义Exception,任何不符合预期的算法直接拦截。

敏感信息这一条也很关键,JWT的payload只是Base64编码,不是加密。我见过有人把用户手机号、邮箱、住址全塞进payload,美其名曰“提高服务端查询效率”,结果Token在分享给第三方调试时,整份用户隐私直接裸奔。凡是你不希望客户端看到的数据,一律不要放Token。

5.2 排查401、500等实际问题的步骤

开发时最常碰到的就是401 Unauthorized。很多朋友看到这个状态码就蒙了,其实按下面这个顺序排查,基本五分钟内能定位:

  1. 确认请求头格式是不是Authorization: Bearer <token>,漏了Bearer前缀或者多了空格,解析逻辑直接拒。
  2. 确认Token没被截断。在线解码网站看一下三段结构是否完整,很多是复制时丢了一段。
  3. 确认服务端时间是否准确。JWT的exp和iat是通过时间差计算的,如果服务器时间和签发机器时间差太多,明明没过期也报无效。同步一下NTP一般能解决。
  4. 确认密钥是否一致。部署了多个环境,配置中心的密钥不相同,生产环境验签失败率会很高。
  5. 确认没有引入黑名单或版本号机制。如果业务代码里加了Redis黑名单或用户版本号,排查时很容易忽略。

有接口调用外部API时还会碰到独立的401,比如unexpected status 401 unauthorized: incorrect api key provided。这通常是API Key配置错误或者权限范围不对,和JWT无关,但很多人会混在一起。遇到这类问题先分清楚是“你自己的JWT校验失败”还是“你调第三方API的Key不对”,链路里往往两个问题都在一起。

5.3 实操中的避坑清单

最后把这几年实际项目里踩过的坑整理成清单,每条都有真实教训:

  • jjwt0.12版本以后,parseClaimsJws改名成了parseSignedClaims,老代码直接抄会编译不过。
  • 自定义Claims里的集合类型,取出来后强转时要小心,Jackson反序列化后可能是List而不是具体集合类型。
  • 不要在日志里打印完整Token,哪怕只是调试日志。生产环境日志级别一降低,全网Token就全泄了。
  • 拦截器里解析Token后不要直接放到request的attribute里完事,建议用一个静态的UserContext保存,请求结束在afterCompletion里清理,防止线程池复用导致用户信息串了。
  • 跨域配置中,allowedHeaders必须包含Authorization,否则浏览器发预检请求时后端不返回对应的响应头,实际请求根本到不了拦截器。
  • 密码类业务和JWT结合时,改密码后必须把旧的Refresh Token全部作废,否则还能用旧密码的会话继续访问。
  • 使用jti时,签发代码里必须每次生成随机UUID,不要复用。否则黑名单机制形同虚设。
  • 在线解析JWT的网站千万不要拿生产Token去试,最好连开发环境Token都别用。自己本地用脚本解析日志都行,别白送数据给别人。

6. 最后分享一个习惯

我个人的习惯是,关于Token的处理永远保持“最小信任”:能不放payload就不放,能短过期就短过期,能加校验就加校验。JWT只是认证体系里的一环,真正保护API的是你对签名密钥的管理、过期策略的设计和每条接口的权限控制。每次有人问我JWT是否安全,我都会反问一句:你的密钥管理得够不够安全?Token过期策略有没有认真设计?这两条做不到,JWT的结构再优雅也是摆设。

在一个项目里把JWT用得顺手之后,你会觉得无状态认证确实省心,但也会发现没有哪个方案是一劳永逸的。多留点日志、多埋几个监控点,比换认证方案更能让你睡得安稳。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/10 19:39:41

Playwright自动化实战指南:从原理到爬虫与AI Agent集成

我拿 Playwright 写了三年自动化代码&#xff0c;从最早拿来爬数据&#xff0c;到后来整个测试团队把脚本全部迁到这套框架上&#xff0c;再到现在各种内部平台把 Playwright 当作执行器来用。可以说&#xff0c;Playwright 这个词已经不只是某个开源库的名字&#xff0c;它已经…

作者头像 李华
网站建设 2026/10/10 19:39:17

Text-to-CAD实战:从自然语言到参数化3D模型的完整流程

1. 为什么“text-to-cad”突然成了硬需求先别急着把它当成又一个昙花一现的AI噱头。我在制造业和设计软件领域泡了十来年&#xff0c;最近半年被同行问得最多的问题就是&#xff1a;用一句话描述一个零件&#xff0c;真的能直接生成可编辑的三维模型吗&#xff1f;先说结论&…

作者头像 李华
网站建设 2026/10/10 19:34:17

Java线程中断机制详解:interrupt()协作式设计与实践

1. 先说清楚 interrupt() 到底做了什么很多写 Java 并发代码的人&#xff0c;第一次见到Thread.interrupt()都会下意识以为它跟Thread.stop()一样&#xff0c;能够强行把一个正在运行的线程干掉。我早年也犯过这个错&#xff0c;线上一个任务线程卡在循环里&#xff0c;我调了i…

作者头像 李华
网站建设 2026/10/10 19:28:32

FlyEnv本地开发环境:按需启动省内存,多版本切换告别环境折磨

干全栈开发这些年&#xff0c;我最崩溃的时刻从来不是在改bug&#xff0c;而是在配环境。以前我的电脑上同时躺着PHPStudy、XAMPP&#xff0c;后来为了跑微服务又装了Docker Desktop&#xff0c;三个工具加起来&#xff0c;先不说安装目录有多乱&#xff0c;光是它们各自带的My…

作者头像 李华
网站建设 2026/10/10 19:26:07

Spring Boot毕设利器:实验室器材智能管理平台从设计到实现全解析

每年到了毕业季&#xff0c;计算机专业的群里总会被同样的问题刷屏&#xff1a;“毕设做什么题目好&#xff1f;”“Spring Boot的课题好过吗&#xff1f;”“实验室管理系统是不是太烂大街了&#xff1f;”作为一个带过不少毕业生、也帮人改过无数次论文的老学长&#xff0c;我…

作者头像 李华