☰
JWT从原理到安全实践:Header/Payload/签名、算法混淆与Token续签全解析
2026/9/28 13:21:02 网站建设 项目流程

1. JWT的Header、Payload、Signature到底怎么拼

很多人一上来就背JWT三段式结构,知道是Header.Payload.Signature,但真到了排查问题的时候,自己手写个Base64解码就开始懵。我觉得先把这三段彻底拆开讲透,后面所有关于漏洞、续签、整合的讨论才立得住。

1.1 Header里藏着什么

Header是一个JSON对象,常规长这样:

{ "alg": "HS256", "typ": "JWT" }

typ一般固定是JWT,提示这是标准JWT数据;alg是签名算法,我遇到过不少项目在开发环境里图省事,把alg直接设成none,这可能直接就把认证绕过的大门打开。实际生产里,alg主要是HS256/HS512(对称算法,同一个secret加签验签)和RS256/RS384/RS512(非对称算法,私钥签名、公钥验签)。还有一类是ES256,椭圆曲线算法,比较少见,但也在陆续遇到。

Header里有时还会出现kid字段,代表Key ID,用来告诉服务端“我这个Token是用哪把密钥签的”。这个字段看起来是贴心的设计,却也常常成为攻击面,后面我会单独开一节说。

1.2 Payload能放什么、不能放什么

Payload是Token携带的声明数据,分三类:

  • Registered claims:官方建议的公共字段,比如iss(签发人)、sub(主体)、aud(受众)、exp(过期时间)、nbf(生效时间)、iat(签发时间)、jti(唯一ID)。
  • Public claims:业务自定义字段,但为了防止和被保留字段冲突,最好通过IANA注册或加上命名空间前缀。
  • Private claims:服务端和客户端约定的私有字段,最常见的是userId、username、role。

我见过很危险的做法是把password哈希存进Payload(为了省一次DB查询),这不光是Base64可解码的问题,任何人都可以解码看到它,虽然哈希很难逆推,但哈希已经暴露给所有人,等于把离线爆破的素材交出去了。Payload里的数据真的只适合放“非敏感、非机密的身份标识”,要记住:Payload不是加密的,是裸的Base64。

1.3 Signature的生成规则与验证规则

签名部分跟算法绑定,但不管哪种算法,都逃不开这个思路:

signature = HMACSHA256(base64UrlEncode(Header) + "." + base64UrlEncode(Payload), secret)

实际用的不是普通Base64,而是Base64URL编码,把+换成-、/换成_,并去掉末尾的=。验签就是服务端用同样的密钥、同样的算法重新拼一次签名,比对是否一致。这里藏着最关键的一个陷阱:任何JWT安全问题的本质,都是服务端到底信了Token里的哪个字段。算法降级、密钥混淆、kid注入,全都是围绕“验证方式可被客户端控制”这一点做文章。

从工具实战角度,我最常用的是jwt.io这个网页工具去做快速解码验证,但如果要批量解析、或者在脚本里做爆破,我一般用Python的PyJWT:

pip install pyjwt
import jwt token = "xxx.yyy.zzz" # 只解码Header和Payload,不验签 header = jwt.get_unverified_header(token) payload = jwt.decode(token, options={"verify_signature": False}) print(header) print(payload)

注意verify_signature=False只能用来排查数据结构,绝对不能出现在生产验签逻辑里。用PyJWT做正确验签时,HS256这样写:

decoded = jwt.decode(token, secret, algorithms=["HS256"])

要点提醒:algorithms参数一定要显式传,如果写成algorithms=None,旧版本PyJWT可能不校验算法,直接按Header里的alg走——这就是算法混淆漏洞产生的根源之一。

2. HS256和RS256的选择,以及算法混淆攻击为什么能得手

选型问题在JWT项目里永远绕不开,我几乎每次评审代码都会问一句:“签名算法定的哪种?”有人觉得HS256语法看起来更简洁,有人觉得RS256要生成密钥对更麻烦,但安全性差异巨大。

2.1 对称与非对称的本质区别

HS256是对称算法,加签和验签用的是同一个密钥。所以这个密钥必须保存在服务端,不能让客户端知道。所有需要验证JWT的服务端实例,都要共享同一把secret。如果系统里有多个微服务都要校验JWT,secret被任何一个服务泄露,整个系统的Token都可以被伪造。

RS256是非对称算法,私钥负责签名,只有持有私钥的服务能发Token;公钥公开分发,各服务只做验签。哪怕某个验签服务的公钥泄露,攻击者也无法伪造签名,因为他没有私钥。这也是现代微服务架构里推荐RS256的原因——验签方不需要接触私钥。

2.2 算法混淆攻击的完整链路

算法混淆攻击(Algorithm Confusion)是JWT最经典的攻击方式之一。它利用的是服务端在验签时同时支持多种算法、但没有验证算法是否与预期一致的缺陷。

攻击思路是这样的:

  1. 拿到一个合法Token,解码Header,得知正常算法是RS256;
  2. 攻击者想办法搞到服务端的公钥(公钥通常可以从认证服务的JWKS端点获取,比如/.well-known/jwks.json);
  3. 攻击者把Header里的alg改成HS256,然后用这个公钥的内容当作HS256的对称密钥,重新对Header和Payload计算签名;
  4. 服务端如果看到alg=HS256也接受了RS256公钥参与HMAC验签,那么攻击者就伪造成功了。

这看起来非常不可思议:公钥明明是公开的东西,怎么能当对称密钥用?但很多实现里,验签逻辑是“Header说用什么算法,我就用什么算法”,没有校验这个Token在签发时到底该用哪种算法。当服务端误用公钥作为HMAC密钥时,攻击者就成功了。

防范方法只有一条:验签时严格白名单算法。比如里面的签名算法实现:

JwtParser parser = Jwts.parserBuilder() .setSigningKey(publicKey) .build();

这种写法太弱了,必须显式声明:

Jwts.parserBuilder() .setSigningKey(publicKey) .setAllowedClockSkewSeconds(60) .build();

在大多数框架里,正确的做法是:

Jwts.parserBuilder() .setSigningKey(publicKey) .setAllowedClockSkewSeconds(60) .setUnsecuredDisabled() .build();

不同库的API不一样,但核心就一句话:只允许RS256/RS384等预定的白名单算法,禁止服务端根据Header的alg字段动态选算法。

2.3 实际项目里我为什么优先推荐RS256

抛开攻击面不谈,就从工程演进角度看:一个系统早期可能只有一个认证中心,HS256够用且快;但当业务拆分后,多个后端服务要校验Token,如果全部共享同一个HS256 secret,密钥管理就成了一个大问题——改密钥要所有服务同步改配置,任何一个服务的配置文件泄露都会拖垮全体。

RS256虽然计算开销稍大(且现代CPU上完全可忽略),但私钥集中在认证中心,其他服务只需要信任公钥,可以随时轮换密钥对,对服务影响面小得多。所以,只要系统存在多个服务,我一般就直接用RS256,省得后期重构。

3. 从登录到校验:无状态认证的完整调用链

项目热点里提到的“JWT实现Token登录验证”和“JWT实现Token续签”都依赖这条调用链的理解。我把完整流程拆开来讲,顺带解释为什么JWT适合做认证、却不适合做会话管理。

3.1 无状态认证的设计动机

传统Session方案里,用户登录后,服务端把Session ID存内存或Redis,客户端存Cookie。每次请求,服务端要去Redis查一下这个Session是否存在、有没有过期。这带来了两个问题:

  • 服务端内存/Redis压力大;
  • 分布式部署时,要么引入集中存储,要么配置Session同步,运维复杂度上来了。

JWT的思路是,把用户身份信息签成Token发给客户端,之后每次请求只要带上它,服务端验签通过即信任内容。服务端不需要存任何会话数据——这就是“无状态”的含义。

3.2 标准调用链

我画一个我在项目里落地时最常描述的时序(不依赖具体框架语言):

  1. 用户POST账号密码到/api/login;
  2. 认证服务校验密码,成功后生成JWT(一般有效期2小时到24小时之间),返回给客户端;
  3. 客户端把Token存到localStorage或cookie;
  4. 前端每次请求在Authorization: Bearer <token>里带上;
  5. 后端网关或业务服务拿到Token,先校验签名,再校验exp,然后取出userId/role等字段;
  6. 业务代码不再从Session拿用户信息,而是从Token Claims里拿。

第5步是最容易被简化、也最容易出错的地方。很多团队把验签放在业务代码的过滤器里裸写JWT解析逻辑,完全没有统一的认证过滤器,这会导致每个服务的实现不一致:有的校验太松、有的校验太严。如果做网关层统一验签,把用户信息透传给下游服务,下游就不要重复解析JWT了。

3.3 为什么说“无状态”是一把双刃剑

JWT的好处是服务端零存储,但代价是失去了“主动失效能力”。用户被踢下线、改密码、被封禁——Token在过期之前依然是合法的。我在实际项目中就处理过这个尴尬场景:

  1. 用户修改了密码;
  2. 但旧Token在2个小时内仍然有效;
  3. 攻击者如果已经拿到旧Token,还能在这段时间内访问系统。

解决这个问题,单靠JWT本身做不到,必须在登录时把密码版本号(或用户状态版本号)放进Payload,每次请求到业务服务或认证服务时,再去比对当前版本;版本不一致就直接拒绝。这也是前面Payload设计里我提示过的:敏感数据不要放,但版本号、会话ID这类“锚点”数据应该放。

4. 那些年踩过的JWT漏洞:密钥爆破、kid注入和Nacos默认密钥

这个章节是热点里“JWT漏洞总结”“JWT Kid”“Nacos默认密钥身份认证绕过漏洞CNVD-2023-17316”“JWT发包格式”的集中落点。我按攻击面一个一个讲透。

4.1 密钥爆破:JWT安全强度不取决于算法,取决于secret

HS256的密钥是普通字符串,如果密钥足够长、随机性足够强,爆破难度极高;但如果开发者偷懒,把secret写成secret、123456、jwt这种弱口令,攻击者只需要收集到一个合法Token,就能离线爆破。

爆破工具很多,我最常用的是c-jwt-cracker(速度还行)和hashcat(配合模式规则,可以跑字典)。但作为开发者,比爆破手段更重要的是理解一件事:HS256的密钥必须暴露越少越好,且绝不能入库代码仓库。有人把secret写在application.yml提交到Git,这等于把安全神话打破了一半。

一个可用但仍有风险的思路是用环境变量挂载密钥,例如:

jwt: secret: ${JWT_SECRET} expiration: 7200

对攻击者来说,密码弱不弱,看Token复杂度基本就能判断。之前有一种较常见的方式,是把secret长度做得够长(比如32字节以上随机数),防止被字典命中。但更强的做法是干脆用RS256,不存在“共享secret”的问题,也就没有爆破入口。

4.2 kid参数注入:一个Header字段引发的命令执行

kid是Header里的“Key ID”,用来告诉服务端用哪把密钥。在很多开源实现里,服务端会拿到kid去拼一个路径,比如/keys/{kid}.pem,然后用这个文件作为验签密钥。问题出在哪?

如果开发者直接拿kid做字符串拼接,攻击者可以传../../etc/passwd,让服务端加载一个攻击者可控的文件——甚至如果代码里有curl或者读取远程URL的逻辑,kid可以直接指向http://evil.com/key.pem,攻击者就能把自己的公钥塞给服务端,让服务端“信任”攻击者签名的Token。

我还见过更极端的案例:开发者在验签代码里用了某种表达式引擎去动态计算kid,结果被注入表达式,直接执行了命令。虽然这类案例不多,但足以说明:Header里的参数,必须当作不可信输入对待。

经验做法是:kid不要直接用来拼路径,而是用一个Map维护kid -> 密钥对象,查不到就拒绝;如果一定要支持多密钥轮换,也要把查询逻辑限定在白名单表里,不要开放“任意文件读取”。

4.3 Nacos默认密钥(CNVD-2023-17316)复盘

热点里提到的“在Nacos默认密钥身份认证绕过漏洞CNVD-2023-17316,攻击者可利用默认JWT密钥伪”就属于这类问题。Nacos是一个开源的服务发现/配置中心组件,它内置了一个JWT认证机制。问题在于:Nacos的默认配置使用了固定的、公开的JWT密钥(代码仓库里的默认secret),大量用户部署后没有修改这个密钥。

攻击者只需要知道目标Nacos版本默认密钥,自己伪造一个JWT(Claims里带username: nacos、role: ROLE_ADMIN之类的管理员字段),然后把伪造的Token放进请求头,就能绕过身份认证,直接调用Nacos管理接口,读取、篡改配置中心里的配置。如果配置中心里存有数据库连接串、中间件账号密码,这基本就是把整个生产环境钥匙交了出去。

这个案例带给我两个非常深的体会:

  • 任何默认密钥、默认口令都是潜在的漏洞面,部署文档必须要求用户强制修改;
  • JWT漏洞不只是代码级的问题,也是部署运维和环境管理的问题。如果容器镜像里已经带了默认密钥,即便代码写得再好,部署出来也照样被绕过。

4.4 发包格式:从攻击者视角看JWT请求

“热搜词”里有一项是“JWT发包格式”,安全和开发两类人关注的点不太一样。开发关注的是正确格式,比如:

POST /api/user/info HTTP/1.1 Host: example.com Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.longsignature Content-Type: application/json

安全测试人员关注的是“怎么改包”,一般流程是:

  1. 用Burp Suite拦截登录请求,拿到合法Token;
  2. 把Token复制到JWT工具(或手动脚本里)做解码;
  3. 修改Payload里的userId/role/exp;
  4. 使用目标密钥/算法重新签名,再放回Authorization头发包。

如果服务端验签不到位,修改后的Token就能通过校验,而且你还可以测出不少问题,比如:

  • 删除Signature后Token仍然被接收(有些实现竟然不校验签名);
  • exp改成未来时间后Token依然有效(某些实现没有认真判断时间字段);
  • 把alg改成none后直接通过。

所以作为开发方,自测JWT时一定要用攻击者的视角过一遍上述流程,不要只测正常链路。很多时候我接手项目第一件事,就是用这种发包方式去验证线上环境的JWT是否真的不能被篡改。

5. Token续签不能拍脑袋:三种续签方案的取舍

“JWT实现Token续签”也是热搜里的高频词,说明很多人被过期问题困扰。无状态Token的过期机制比Session麻烦得多,因为它无法在服务端动态延长。我梳理了实践中三种主流续签方案。

5.1 方案一:Redis版本号 + 短Token

这个方案的核心是不完全依赖JWT的exp,而是在Redis里存一个“当前用户Token版本号”或者“最后过期时间戳”。

具体做法:

  1. 登录时生成Token,把jti(或userId)写入Redis,设置过期时间和Token的exp一致;
  2. 每次请求,验签通过后再查一次Redis,确认这个jti还有效;
  3. 用户请求一次就刷新一次Redis的TTL,只要用户持续活跃,Token就不断顺延;
  4. 一旦Redis里的记录过期,即使JWT的exp还有几分钟,也直接拒绝。

这个方案的优点:

  • 仍然保留JWT的无状态验签优势;
  • Redis这里相当于一个轻量的“会话状态层”,解决了主动失效问题;
  • 实现简单,不涉及刷新接口。

缺点是:每来一个请求都要查Redis,等于部分放弃了“无状态”的初衷,但换来的是可控性,这个取舍我认为在多数业务场景下值得。

5.2 方案二:Refresh Token双Token

这是业界最常见的长短Token方案,也是我推荐做单页应用和移动端时使用的方案:

  1. 登录成功,返回两个Token:Access Token(短,15分钟到2小时)和Refresh Token(长,7天到30天);
  2. 前端每次请求带Access Token;
  3. Access Token过期后,前端拿Refresh Token请求/api/auth/refresh,换取新的Access Token(顺带旋转Refresh Token,旧的作废);
  4. 如果Refresh Token也过期,前端静默跳登录页。

这个方案的精华在于,Refresh Token不能只做“延长”一个动作,必须做“轮换”,每次刷新都签发新的Refresh Token,旧Refresh Token作废。这样即使Refresh Token在某次传输中被窃取,一旦攻击者使用它刷新成功,老Token就失效了,双方会在某个时间点出现“旧Token明明有效但已被使用”的争夺痕迹。

在移动端我用极简方式表示:

1. POST /login -> { accessToken, refreshToken } 2. GET /resource with accessToken 3. 401 -> POST /refresh with refreshToken -> { newAccessToken, newRefreshToken }

5.3 方案三:滑动续期(Sliding Expiration)

滑动续期的意思是:

Token的过期时间不是固定的,每次有效请求到达后,都往后推一段时间,比如固定30分钟无操作才过期。

这个方案对用户体验最好,但也最危险。因为它在没有服务端状态的情况下,把“永不失效”变成了可能——只要攻击者定期提前带着Token刷接口就行。

所以我现在一般不会单独用纯JWT做滑动续期,而是结合Redis版本号方案,把滑动续期建立在Redis状态之上,不让Token自己“滑动”,而是让Redis里的TTL滑动,这样还能控制异常。

5.4 我踩过的续签坑

第三次做续签功能时我掉过一个坑:Refresh Token没有绑定用户,只存了一个随机串。结果一个用户在两台设备上登录,A设备刷新后把Refresh Token作废了,B设备也被迫下线——用户以为出了Bug。后来我把Refresh Token设计成按deviceId维度区分,A设备的刷新不影响B设备。这个细节,比用什么算法还重要。

6. Spring Security整合JWT的Filter链路与配置要点

热点里明确提到了Spring Security整合JWT,这也是面试和实战双高频场景。我假设你用的是Spring Boot 2.x + Spring Security 5.x这种主流组合,把整合链路完整梳理一遍。

6.1 Filter链的位置

Spring Security默认的过滤器链里,UsernamePasswordAuthenticationFilter负责表单登录,BasicAuthenticationFilter负责Basic认证。整合JWT的思路是插入一个自定义Filter(比如叫JwtAuthenticationFilter),放在UsernamePasswordAuthenticationFilter之前,专门从请求头里取Token、验签、构建Authentication对象,然后塞进SecurityContextHolder。

代码骨架:

@Component public class JwtAuthenticationFilter extends OncePerRequestFilter { private final JwtTokenProvider jwtTokenProvider; private final UserDetailsService userDetailsService; @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { String token = jwtTokenProvider.resolveToken(request); if (StringUtils.hasText(token) && jwtTokenProvider.validateToken(token)) { String username = jwtTokenProvider.getUsername(token); UserDetails userDetails = userDetailsService.loadUserByUsername(username); UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities()); authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); SecurityContextHolder.getContext().setAuthentication(authentication); } filterChain.doFilter(request, response); } }

这段代码有两点必须强调:

  • 验签不通过时不要在这里抛异常,而是让请求继续走过滤器链,最后进入AuthenticationEntryPoint去响应401。如果在Filter里直接抛异常,全局异常处理可能没法覆盖,返回的响应会很不规范。
  • 每次请求都把UserDetails查一遍,这和上面Redis版本号的思路是配合的——如果用户被禁用或调整了角色,下一次请求立即生效。代价是每一个请求都多一次DB查询,通常可以接受;如果接受不了,可以退化成纯JWT Claims授权,但要把用户状态变更的即时性权衡清楚。

6.2 安全配置

@Configuration @EnableWebSecurity public class SecurityConfig { private final JwtAuthenticationFilter jwtAuthenticationFilter; @Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http.csrf().disable() .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS) .and() .authorizeHttpRequests() .requestMatchers("/api/auth/**").permitAll() .requestMatchers("/api/admin/**").hasRole("ADMIN") .anyRequest().authenticated() .and() .exceptionHandling().authenticationEntryPoint(restAuthenticationEntryPoint()) .and() .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); return http.build(); } }

关键设置在sessionCreationPolicy。设为STATELESS后,Spring Security不会再创建HttpSession,这也正好契合JWT无状态认证的目标。AuthenticationEntryPoint要返回JSON 401,而不是默认的重定向到/login页面,否则前端拿到的是302而不是401。

6.3 几个容易踩的Spring整合坑

  • 自定义Filter没有被Spring管理:如果你在配置里new JwtAuthenticationFilter()而不是注入,那Filter里依赖的JwtTokenProvider全是null。正确做法是把自定义Filter定义为Bean并注入到SecurityFilterChain方法里。
  • OncePerRequestFilter和普通Filter的区别:普通Filter在一个请求经多次转发时可能被重复执行,OncePerRequestFilter能保证同一请求只执行一次——JWT解析这种逻辑重复执行不仅浪费性能,还可能造成重复认证覆盖问题。
  • CORS配置被Spring Security拦截:很多项目里CORS没生效,是因为http.cors()没开。跨域预检OPTIONS请求也会被JWT过滤器处理,需要放行。

7. SPA前端的Token管理与刷新配合

热搜词里有一组是“SPA项目开发之JWT验证码实现”,我补上前端这部分。SPA(单页应用)的验证码、Token存储、刷新,都有自己的一套讲究。

7.1 登录页验证码与JWT的关系

JWT本身和验证码没有直接关系,但它们在登录流程里是衔接的:

  1. 前端打开登录页,先请求/api/captcha,后端生成验证码图片并返回一个captchaId;
  2. 用户输入账号、密码、验证码提交到/api/login;
  3. 后端校验验证码通过后,再校验账号密码,然后签发JWT。

关键点在于:验证码的校验应该在JWT签发之前,且验证码必须与用户名/手机号等绑定,否则验证码会被多端复用攻击。普通做法是验证码存在Redis,key是captcha:login:{captchaId},值就是验证码答案,5分钟过期。

7.2 Axios拦截器里做Token刷新

前端体验好坏,很大程度在于401后的处理。我在页面里会这样设计:

  1. 请求发出去,若返回401且当前请求不是/refresh接口;
  2. 用Refresh Token请求刷新接口,拿到新的Access Token;
  3. 更新本地存储的Token;
  4. 重放刚刚失败的请求。

一个相对可靠的伪代码思路:

axios.interceptors.response.use( response => response, async error => { const { response, config } = error; if (response.status === 401 && !config._retry) { config._retry = true; const newAccessToken = await refreshAccessToken(); config.headers.Authorization = `Bearer ${newAccessToken}`; return axios(config); } return Promise.reject(error); } );

稍微注意一下:如果多个请求同时401,它们会各自去刷新,可能导致Refresh Token被并发刷新多次、旧的被作废。所以线上要加“正在刷新中”的锁,其他401请求等待同一个刷新Promise返回。这一步常常被忽略,但却是很多SPA在Access Token过期瞬间出现白屏、请求风暴的根源。

7.3 前端Token存储:localStorage还是Cookie

这个问题我回答过很多次。简单结论是:

  • 如果项目对XSS防护很有信心且不需要跨域Cookie场景,可以用localStorage,方便前端拦截器来控制Header;
  • 如果项目被XSS风险困扰,或者要走httpOnly的安全路线,应把Token放在httpOnlyCookie里,但这样做前端JavaScript就拿不到Token,刷新逻辑要么交给后端Cookie自动续期,要么通过/refresh接口种新Cookie。

我个人的实践经验是:必须从威胁模型出发。对安全性要求较高的系统(比如涉及资金、敏感数据的后台),选httpOnlyCookie + CSRF防护;对普通业务系统,localStorage+ 拦截器刷新已经够用。不要盲目追求“更安全”反而把项目坑进CORS和CSRF的无底洞。

关于“JWT在SPA项目里的验证码实现”,还有一个容易被忽略的小细节:验证码接口本身不能被JWT拦截器拦截(因为它发生在登录前)。很多SPA项目在封装axios时全局带上Authorization头,验证码请求被这个头污染,后端如果严格校验,就会直接403。我在项目里一般专门创建一个不带Token的axios实例给captcha用:

const noAuthAxios = axios.create({ baseURL: '/api' });

这个小设计省了很多次联调返工。

8. 结合Nacos案例的JWT安全自查清单

前面讲了漏洞原理和开发链路,最后的落脚点放在“安全自查”上。因为我发现很多团队不是不知道JWT,而是缺少一个可以直接照着检查的单子。

我在接手项目时会按以下顺序过一遍JWT相关代码:

  • [ ] 是否支持alg: none?支持就必须下线;
  • [ ] Key是否硬编码在代码/配置仓库中?是则必须迁移到环境变量或密钥管理服务;
  • [ ] 是否允许多种算法同时验签?是则改为白名单算法;
  • [ ]kid是否直接拼接文件路径/URL?是则改为映射表;
  • [ ] Token里放了哪些敏感字段?有password、身份证、手机号等就必须移除;
  • [ ] Token过期后后端有没有主动拒绝能力?没有则引入Redis版本校验或双Token;
  • [ ] 刷新接口有没有做旧Token作废轮换?没有则补上;
  • [ ] 登录验证码接口是否被全局JWT拦截器拦住?拦住会导致登录页白屏;
  • [ ] 前端并发401时有没有做刷新加锁?没有会触发请求风暴;
  • [ ] Nacos/网关等中间组件的JWT密钥是否改掉了默认值?没改可能导致绕过漏洞。

每个“否”,都是一个可以立即推进的修复项。我在规整自己项目的过程中,也是拿着这套清单一个个打钩。安全问题往往不是某一个代码写错了,而是一连串配置、假设、疏忽叠加出来的结果。JWT相对其他认证方案本身不算复杂,复杂的是把“验签、时效、失效、刷新、存储”每一环都处理到位。

做了这么多JWT相关的项目之后,我的体会是:技术选型通常不是最大风险,最大的风险是对默认配置和安全假设的盲目信任。Nacos那个漏洞也好,算法混淆也好,本质上都是“默认值被信任”吃到了苦头。如果读完这篇文章,你只记住一件事,那我希望是:所有默认配置都要质疑一遍,所有Header里的字段都要当用户输入处理——这两条,能挡住绝大多数JWT方向的真实攻击。

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

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

立即咨询