☰
JWT认证鉴权后请求仍进不来:401根因与排查
2026/11/2 8:03:25 网站建设 项目流程

周五下班前十分钟,测试同事甩过来一张截图:接口返回 401,附件里是她抓的请求,Authorization 那栏明晃晃写着Bearer eyJhbGciOi...。可我本地用调试工具一调就通,token 也是刚签发的。这类"JWT 认证鉴权后无法进入请求"的问题,几乎每个做过后端鉴权的人都会撞上几次,而且症状高度相似——token 看着没问题,请求就是进不去业务代码,日志里干干净净,连个异常堆栈都不给你留。

这篇内容想聊的就是这件事:JWT 认证鉴权链路走完之后,请求为什么还是进不来。它不是什么高深的安全算法话题,而是一堆分布在请求头、跨域预检、过滤器顺序、密钥配置、路径匹配、网关转发、异常处理里的细节问题,任何一个环节偏了一点,结果都是同一个:401、403,或者更恶心的"返回 200 但数据是空的"。适合已经上手写过 JWT 鉴权、但在联调或上线阶段被卡住的后端同学,也适合前端同学对照着排查自己的请求到底有没有把凭证送出去。我会按"先分清卡在哪一层——再讲链路怎么设计——然后逐条拆根因——最后给一套可复现的排查流程"这个顺序展开,中间穿插我自己踩过的坑和几段可以直接抄的配置。

1. 先搞清楚请求到底卡在哪一层

1.1 "认证失败"和"请求没进来"是两件完全不同的事

很多人一看到 401 就往 JWT 解析代码里钻,翻半天签名逻辑,其实问题根本不在那儿。我习惯先把失败点分成三类,分清楚了再动手,能省掉一大半无效排查。

第一类是凭证没送到。请求里压根没有 Authorization 头,或者有但值是空的、格式错的、被中间某一层剥掉的。这种情况下服务端的 JWT 解析代码一次都没被执行过,你在那里打断点永远打不中。

第二类是凭证送到了但校验没过。签名对不上、过期了、算法不匹配、claim 结构变了、密钥换了没同步。这类问题通常会在日志里留下痕迹,前提是你的异常处理没有把它吞掉。

第三类是校验过了但授权没过。JWT 解出来是合法用户,可这个用户没有访问该路径的角色,或者路径匹配规则写错导致白名单没生效,又或者 SecurityContext 写进去了但在另一个线程里读不到。这类最隐蔽,因为 token 完全正常。

我遇到过最典型的一次是第三类伪装成第一类:接口返回 401,我第一反应是 header 丢了,抓包一看 header 好端端地在,最后发现是自定义过滤器用@Component注册,执行顺序排在安全框架的过滤器链之后,token 解析完了塞进上下文,转头上下文就被清空了。这个坑后面会详细说。

提示:排查前先确认一件事——这个 401 是网关返回的、是框架返回的、还是你自己业务代码里response.setStatus(401)写的。三者来源不同,排查方向差得很远。最简单的办法是看响应体的结构,框架默认的 401 响应体通常很短,你自己写的往往带业务 code 和 message。

1.2 一次带 token 的请求要穿过多少道关卡

把链路摊开看,一个带着 JWT 的请求从浏览器发出到进入业务方法,要经过的关卡比大多数人想的多。我用一个典型的前后端分离项目举例,从前往后数:

浏览器发起请求前,前端的请求拦截器要先从本地存储里把 token 读出来,拼成Bearer xxx塞进 header。这一步可能失败——存储的 key 写错了、登录后没存进去、多标签页之间状态不同步。

请求发出后,如果是跨域且带了自定义头,浏览器会先发一个 OPTIONS 预检请求。这个预检请求不带 Authorization,因为它就是来问"我能不能带这个头"的。如果服务端把 OPTIONS 也纳入鉴权范围,预检直接被 401 打回,真正的请求根本发不出去。这是最高频的根因之一。

请求到达反向代理层,代理可能因为 header 名带下划线而直接丢弃它,也可能因为缓冲区大小限制截断超长 header。JWT 一旦塞了太多自定义 claim,长度轻松过 2KB,这个隐患就真实存在。

请求进入应用容器,经过一串过滤器。安全框架的过滤器链有自己的顺序,你自定义的过滤器如果没有显式指定顺序,位置完全不可控。这里出问题的表现是:token 解析了,但后面的代码读不到用户信息。

进入业务层之前还有一层路径匹配。白名单里的路径写的是/login,实际请求是/api/login;或者应用配了 context-path,实际路径变成了/gateway/api/login,白名单直接失效。这种情况下连登录接口都要 token,形成死锁。

最后是服务间调用。A 服务调 B 服务,token 存在 A 的请求上下文里,如果调用客户端没有配置请求拦截器把 token 透传过去,B 服务收到的就是一个裸请求。

这六道关卡,任何一道出问题,表象都是"进不去"。所以我一直建议团队里的新人在排查这类问题时,先把链路画出来,然后在每一层加一行日志或者打一个断点,确认 token 到底走到了哪一步。二分定位永远比通读代码快。

1.3 为什么这个问题特别容易在联调阶段集中爆发

本地开发环境往往是"最短路径":前端直连后端,没有代理、没有网关、没有跨域、单实例部署、密钥写死在配置文件里。这套环境跑通不代表什么。一旦进入联调或预发,代理加上了、网关加上了、跨域真实发生了、服务起了多个实例、配置从配置中心拉取,前面提到的六道关卡一次性全部到位,问题就开始冒头。

我自己总结的规律是:JWT 相关问题有八成集中在"环境差异"上,而不是代码逻辑本身。所以下面讲根因的时候,我会尽量把"什么环境下会触发"说清楚,这样你对照自己的部署形态就能快速缩小范围。

2. 鉴权链路的设计取舍,决定了你会踩哪些坑

2.1 网关统一校验还是各服务自行校验

这是架构层面第一个要做的决定,而且它直接决定了你后面排查问题的复杂度。

网关统一校验的做法是:所有外部请求先过网关,网关负责解析 JWT、校验签名和有效期、把用户信息塞进请求头(比如X-User-Id)转发给下游,下游服务信任这个头,不再自己解析。好处很明确——校验逻辑只有一份,密钥只有网关持有,下游服务清爽。坏处是两个:一是下游服务如果被内网其他服务直接调用,就绕过了校验,必须靠网络隔离兜底;二是网关转发时容易出幺蛾子,比如头名带下划线被丢、头太大被截断、大小写被改写。

各服务自行校验的做法是:每个服务都引入鉴权组件,自己解析 token。好处是服务自治,坏处是密钥要分发到每个服务,密钥轮换时得全量同步,而且一旦某个服务的依赖版本不一致,解析行为就可能出现差异。

我个人的倾向是混合模式:网关做第一道粗筛(签名、有效期、黑名单),把明显非法的请求挡在外面,减轻下游压力;下游服务做第二道细筛(权限、资源归属),因为权限判断往往需要业务数据,网关做不了。但这里有个关键约束——下游必须能区分"请求来自网关"还是"请求来自内网直连",否则第二道筛子形同虚设。常见的做法是网关转发时加一个内部签名头,下游校验这个签名。

选型上没有什么绝对正确,但有个原则值得坚持:校验逻辑的实现只能有一份代码,哪怕它在多个服务里被引入,也必须是同一个依赖包、同一个版本。我见过一个项目,三个服务分别拷了一份 JWT 工具类,其中一份在处理 Base64URL 无填充字符时用了不同的解码方式,导致同一个 token 在两个服务上表现不一致,排查了整整一天。

2.2 过滤器、拦截器、切面,三种落地方式差在哪

同样是"在请求进入业务代码前校验 token",落地方式有好几种,它们的执行时机和能拿到的东西完全不同,选错了就会遇到"上下文拿不到""异常捕获不到"这类问题。

落地方式执行时机能拿到什么典型坑
Servlet Filter最早,容器级原始请求响应对象,拿不到控制器方法信息顺序不可控,容易与其他过滤器冲突
框架拦截器较晚,进入框架分发后能拿到处理方法、参数注解执行太晚,静态资源、错误页也会经过
方法级切面最晚,进入业务方法时方法签名、参数、返回值无法拦截未进入方法体的请求,异常处理复杂
安全框架过滤器链早,但在自定义 Filter 之后上下文、认证对象顺序与自定义 Filter 的相对位置最容易出错

这张表最想强调的是最后一行的坑。安全框架的过滤器链本身也是 Filter,它和你的自定义 Filter 之间谁先谁后,取决于注册时的顺序值。如果你用注解方式注册自定义 Filter 而不指定顺序,它大概率会排在安全过滤器链之后,于是出现这样的时序:安全框架先跑,发现没有认证信息,直接拒绝;或者反过来,你的 Filter 先解析了 token 写进上下文,安全框架跑的时候上下文又被重置。

正确的做法是显式指定顺序,或者更稳妥一点,直接把自定义的校验逻辑做成安全框架的认证过滤器插进它的链里。关于具体的配置写法,第 4 章会给示例。

注意:很多项目里同时存在"全局过滤器"和"安全过滤器链"两套鉴权,一套在网关、一套在服务内。这种情况下一定要确认两套逻辑对 token 格式的理解是一致的,尤其是前缀处理——一套认Bearer带空格,另一套不认,就会出现"网关放行、服务拒绝"的诡异现象。

2.3 无状态会话的边界怎么划

JWT 的核心卖点是服务端不存会话状态,天然适合水平扩展。但"完全无状态"在实际项目里很难做到,因为总有一些需求需要服务端记住点什么:用户主动登出要让 token 立刻失效、密码修改后旧 token 要作废、管理员要能踢人下线。

这就产生了一个设计上的取舍。我的做法是分层处理:

短期访问令牌保持无状态,有效期设得短一些,比如 30 分钟,签名校验通过就放行,不做任何服务端查询。这样绝大部分请求的处理路径是最快的。

长期刷新令牌可以存状态,放缓存里,支持主动失效。刷新令牌只在换取新访问令牌时使用,频率低,多一次查询不影响整体性能。

需要即时失效的场景,用令牌版本号兜底:用户表里存一个版本号,签发 token 时写进 claim,校验时比对。版本号变了,所有旧 token 立刻失效。这个查询可以缓存,成本很低。

有效期怎么定?我给一组常被问到的参数和推导过程。访问令牌有效期 T,刷新令牌有效期 R,用户活跃时长假设为每天 8 小时连续使用。如果 T 设得太长,比如 24 小时,那么用户登出后 token 依然有效一整天,风险敞口太大;设得太短,比如 5 分钟,刷新频率过高,每次刷新都要走一次网络请求,体验和压力都不好。实践下来 T 取 15 到 30 分钟是个平衡点,R 取 7 天,配合滑动续期(每次刷新时刷新令牌也延长期限),用户一周内用过就不会掉线。

这里还有一个容易被忽略的参数:时钟偏移容忍度。签发服务和校验服务如果不是同一台机器,系统时间可能有几秒到几十秒的差异。如果校验时严格比较当前时间 > exp,一个刚签发、有效期 30 分钟的 token 不会出问题,但如果有效期设得很短(比如 60 秒的临时令牌),时间差就可能导致"刚签发就过期"。所以校验逻辑里应该留一个容忍窗口,常见取值 30 到 60 秒。

3. 逐条拆解:那些让请求进不来的真实原因

3.1 请求头格式:一个空格和大小写就能让你查一下午

这是出现频率最高、也最容易验证的一类。JWT 在 HTTP 头里的标准写法是Authorization: Bearer <token>,注意Bearer后面有一个空格。我见过太多情况是:

前端拼的是Bearer+ token 直接字符串相加,中间漏了空格,服务端按空格切分取第二段,拿到的是空或者整个字符串,解析直接失败。还有一种是把方案名写成了别的词,服务端严格匹配Bearer前缀,对不上就拒绝。

大小写也值得说一句。HTTP 头名本身是大小写不敏感的,authorization和Authorization等价,但头的值里的方案名在多数实现里是严格比较的。有的库要求Bearer首字母大写,有的做了忽略大小写处理。跨语言、跨框架对接时这里经常对不上。我的建议是服务端解析时做一次忽略大小写的匹配,前端则统一按标准写法输出,两头都稳。

还有一种隐蔽情况:同一个头被设置了两次。比如前端拦截器统一加了一次,某个具体接口又手动加了一次,某些客户端会把它们合并成逗号分隔的一个值,服务端解析出来就是一串乱七八糟的东西。排查方法很简单,把收到的原始头值完整打出来看一眼。

下面这段是常见的解析写法,重点在于容错:

public String resolveToken(HttpServletRequest request) { String raw = request.getHeader("Authorization"); if (raw == null || raw.isBlank()) { // 部分场景下前端把 token 放在自定义头里,做一次兜底 raw = request.getHeader("X-Access-Token"); } if (raw == null || raw.isBlank()) { return null; } raw = raw.trim(); // 兼容带前缀和不带前缀两种写法,忽略大小写 if (raw.regionMatches(true, 0, "Bearer ", 0, 7)) { return raw.substring(7).trim(); } return raw; }

这段代码看起来平淡,但regionMatches(true, ...)那个忽略大小写的比较、以及trim()处理首尾空白,是我在被坑过两次之后加的。特别是trim()——有的客户端会在头值末尾带上换行符,肉眼完全看不出来,日志打出来也是一行,但签名校验就是过不去。

3.2 跨域预检:OPTIONS 请求死在了鉴权门口

如果你的前端和后端不同源,浏览器在发真实请求之前会先发一个 OPTIONS 预检。这个预检请求的特点是:不带 Authorization 头,不带 Cookie,不带请求体。它只带几个Access-Control-Request-*头,问服务端"我接下来要发的这个请求,你允许吗"。

问题就来了:如果服务端的鉴权逻辑对所有路径、所有方法生效,OPTIONS 请求会被当成"未携带凭证的非法请求",直接返回 401。浏览器看到预检失败,真实请求压根不会发出去,控制台里报的是跨域错误,很容易误导人往跨域配置上找。

正确的处理有两层。第一层是在鉴权逻辑里放行 OPTIONS 方法,这个判断要放在最前面,早于任何 token 解析。第二层是跨域配置里明确允许 Authorization 头,否则预检通过了浏览器还是不放行真实请求。

// 鉴权过滤器里,第一件事就是放行预检 if ("OPTIONS".equalsIgnoreCase(request.getMethod())) { response.setStatus(HttpServletResponse.SC_OK); return; // 直接结束,不进入后续校验 }

跨域配置这块也有个经典坑:如果allowCredentials设成 true,allowedOrigins就不能用通配符*,必须写具体来源,或者用允许来源模式的写法。这个组合写错了,浏览器会直接拒绝,报的还是跨域错误,跟鉴权一点关系都没有,但现象上完全一致。

提示:判断到底是不是预检问题,最快的办法是在浏览器开发者工具的 Network 面板里看有没有那条 OPTIONS 请求。如果有且状态是 401,那就是它;如果没有 OPTIONS 请求但依然失败,说明是简单请求或者同源,往别处找。

3.3 过滤器顺序:token 解析了,上下文却是空的

这个坑我在第 1 章提过,这里展开说。现象是:日志里明明打出了解析成功的用户 ID,但到了业务代码里取当前用户,拿到的是空,最后被权限判断拦下返回 401 或者 403。

根因在于上下文的存储机制。多数框架把认证信息存在一个线程绑定的容器里,请求进来时由安全框架的过滤器负责填充,请求结束时负责清空。如果你的自定义过滤器执行在安全框架之后,那么它填充的上下文会在这个请求的后续处理中被安全框架视为"未认证";如果执行在之前,填充的上下文可能被安全框架的初始化逻辑覆盖掉。

解决方式有两种。一种是显式指定自定义过滤器的顺序值,让它稳定地排在安全框架过滤器之前;另一种是干脆不用自定义 Filter,改成实现安全框架提供的认证过滤器接口,把逻辑插进它自己的链里,位置由框架保证。

// 方式一:显式注册并指定顺序,避免依赖默认值 @Bean public FilterRegistrationBean<TokenAuthFilter> tokenAuthFilterRegistration(TokenAuthFilter filter) { FilterRegistrationBean<TokenAuthFilter> registration = new FilterRegistrationBean<>(filter); registration.addUrlPatterns("/api/*"); // 数值越小越靠前,确保在安全过滤器链之前执行 registration.setOrder(Ordered.HIGHEST_PRECEDENCE + 10); return registration; }

还有个更隐蔽的变种:过滤器注册时用了@Component注解,同时又用FilterRegistrationBean注册了一遍,结果这个过滤器被注册了两次,执行两遍。第一遍解析成功并写入上下文,第二遍再解析一次,某些实现里会把上下文覆盖成未认证状态。表现就是"偶发失败",特别难查。判断方法是在过滤器的入口打一行日志,看看同一次请求是不是打了两次。

3.4 签名与算法:密钥一致不代表能验通

签名校验失败是第二大类。这里面有好几个层次的坑。

最基础的是密钥不一致。开发环境密钥写在配置文件里,测试环境从配置中心拉,如果两边的值差一个字符——比如末尾多了个空格、换行符处理方式不同——签名就永远对不上。这种情况日志里通常会有明确的"签名不匹配"提示,相对好查。麻烦的是多实例部署时,某个实例拉取配置失败用了默认值,导致同一个 token 打到不同实例结果不同,表现为"刷新几次有时成功有时失败"。这类问题的排查方法是:在签名校验失败的分支里,把密钥的摘要(比如取哈希前 8 位)打进日志,对比各实例是否一致。

再往上一层是算法不匹配。JWT 的头部里指定了签名算法,比如 HS256 或 RS256。如果签发端用 HS256(对称密钥),校验端却按 RS256(公私钥)去验,一定失败。更隐蔽的是某些库在解析时,如果头部声明的算法与预期不符,会抛出异常而不是返回 false,异常如果被全局处理器吞掉,你就只能看到一个 401。

这里要特别提醒一个安全实践:校验时必须显式指定允许的算法,不能让头部里的声明决定用什么算法验签。否则攻击者可以把头部改成"不签名"算法,绕过校验。这不是本文重点,但写鉴权代码时一定要记住。

最后是Base64URL 编码的边界情况。JWT 的三段都用 Base64URL 编码,标准规定要去掉末尾的填充字符=。有些解析库对带填充和不带填充的处理不一致,跨语言对接时容易出问题。判断方法很简单:把 token 按.切成三段,看每段的长度是不是 4 的倍数,如果不是,说明确实去掉了填充。这时候如果某个库报解码异常,就是这个原因。

3.5 claim 结构变化与取值方式

token 校验通过了,但业务代码里取用户信息取不到,抛了空指针,最后被包装成一个奇怪的错误。这类问题的根源往往是 claim 的键名或类型变了。

举个具体的:签发时把用户 ID 放在sub里,值类型是字符串;业务代码里用get("userId")去取,取到 null;或者签发时用数字类型存userId,解析出来是 Integer,业务代码按 String 强转,抛类型转换异常。

还有一种情况是标准 claim 的语义被误用。exp是过期时间,单位是秒级时间戳,不是毫秒。这个错误我见过不止一次:签发时传了毫秒值,解析出来发现过期时间是几万年后,token 永不过期;或者反过来,校验时按毫秒比较,所有 token 一签发就过期。这类问题看日志里的 exp 数值大小就能判断——十位数是秒,十三位数是毫秒。

排查 claim 问题最直接的办法,是把解析出来的整个 payload 打印出来看一眼。调试阶段这么做完全没问题,但上线前一定要把这类日志去掉或者脱敏,因为 payload 里可能有手机号、身份证号这类信息。我一般的做法是只在 trace 级别打印完整 payload,并且对敏感字段做掩码。

3.6 路径匹配与白名单失效

这类问题的表现很特别:登录接口本身需要 token,但用户还没登录哪来的 token,形成死锁。

原因通常是路径写法对不上。常见的几种偏差:

  • 白名单写/login,实际请求/api/login
  • 应用配置了 context-path 为/gateway,实际路径变成/gateway/api/login
  • 反向代理做了路径重写,转发到后端时路径变了
  • 框架版本升级后,路径匹配的 API 换了,旧写法编译能过但运行时不生效

注意:路径匹配这块有个大坑——很多框架在较新版本里把基于 Ant 风格路径的匹配方法标记为过时甚至移除了,改成了另一种匹配器。如果你的项目是从旧版本升上来的,升级后编译报错你会改,但如果用的是配置类里的字符串写法,可能编译不报错但运行时不生效,白名单静默失效。

排查方法:在鉴权逻辑里,把当前请求的实际路径和配置的白名单都打出来,一眼就能看出差异。这个日志值得常驻,成本极低,收益很高。

4. 一套可复现的排查流程和可直接抄的配置

4.1 五步定位法,从后往前还是从前往后

我的习惯是从请求发出端往服务端走,因为越靠前的问题越容易验证。

第一步,看浏览器实际发出的请求。打开开发者工具,找到那条失败的请求,展开请求头,确认 Authorization 是否存在、值是否完整。如果是跨域场景,先看有没有 OPTIONS 请求,它的状态码是多少。这一步能排除掉大概三成的问题。

第二步,看服务端收到的原始请求。在鉴权逻辑的最前面加一行日志,打印请求方法、路径、所有头名。注意是头名不是头的值,值里带 token 不打全。这一步能发现"前端发了但服务端没收到"的情况,比如被代理剥掉了。

第三步,看 token 解析结果。在解析成功后打印 payload 的键名和关键字段,在解析失败的分支打印具体异常类型和消息。这一步能定位签名、过期、格式问题。

第四步,看上下文传递。在业务方法入口打印当前用户信息,如果这里为空但第三步有输出,问题就在过滤器顺序或者上下文传递上。

第五步,看权限判定。如果前四步全部正常但依然进不去,问题在授权规则上,检查角色、权限、路径匹配。

这套流程的价值在于每一步都有明确的输入输出,不会出现"我觉得应该是这里"的猜测。我团队里现在排查鉴权问题基本都按这个来,平均定位时间从半天压缩到半小时以内。

4.2 关键配置示例:三个最常见的位置

安全框架的链配置,重点是白名单和预检放行:

@Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http .csrf(csrf -> csrf.disable()) .cors(cors -> cors.configurationSource(corsConfigurationSource())) .sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .authorizeHttpRequests(auth -> auth // 预检请求直接放行,必须放在最前面 .requestMatchers(HttpMethod.OPTIONS, "/**").permitAll() // 白名单,注意与实际路径完全一致 .requestMatchers("/api/auth/login", "/api/auth/refresh").permitAll() // 健康检查、错误页也要放行,否则错误转发会二次鉴权 .requestMatchers("/actuator/health", "/error").permitAll() .anyRequest().authenticated() ) .addFilterBefore(tokenAuthFilter, UsernamePasswordAuthenticationFilter.class); return http.build(); }

这段里"/error"那一行值得单独说。应用的错误处理机制在发生异常时会把请求转发到/error,这是一次新的请求分发,会重新走一遍过滤器链。如果/error不在白名单里,就会出现"原始异常是 A,但用户看到的是 401"的情况,排查时完全被误导。我遇到过最离谱的一次,是鉴权失败触发错误转发,错误转发又被鉴权拦截,日志里刷了几百条 401,真正的原因被埋在中间。

跨域配置,重点在允许的头和方法:

@Bean public CorsConfigurationSource corsConfigurationSource() { CorsConfiguration config = new CorsConfiguration(); // 不能同时用通配符和凭证,这里写具体来源模式 config.setAllowedOriginPatterns(List.of("https://*.example-internal.test")); config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS")); // Authorization 必须显式列出,否则预检不通过 config.setAllowedHeaders(List.of("Authorization", "Content-Type", "X-Requested-With")); config.setExposedHeaders(List.of("Authorization")); config.setAllowCredentials(true); config.setMaxAge(3600L); UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); source.registerCorsConfiguration("/**", config); return source; }

setMaxAge那个值也值得说。预检结果是可以缓存的,设成一小时意味着同一来源的请求一小时内只需要预检一次,能明显减少 OPTIONS 请求量。但如果你的允许头经常变(比如灰度期间),这个值设太大反而会导致缓存了旧规则,改完配置要等一小时才生效,容易误判成"配置没生效"。

反向代理层,重点在头的保留:

location /api/ { proxy_pass http://backend_service; proxy_set_header Host $host; # 显式透传鉴权头,避免被默认行为丢弃 proxy_set_header Authorization $http_authorization; proxy_set_header X-Real-IP $remote_addr; # 如果自定义头带下划线,必须开启,否则头会被静默丢弃 underscores_in_headers on; # 预留足够的头缓冲区,JWT 携带多 claim 时长度容易超标 large_client_header_buffers 4 16k; proxy_read_timeout 30s; }

underscores_in_headers on这行是我用真实教训换来的。代理默认会丢弃名字里带下划线的头,这是安全考虑。如果你的自定义鉴权头恰好写成X_User_Token这种带下划线的形式,它会在这一层凭空消失,前端发了、后端收不到,中间没有任何日志。改成中划线命名是更好的做法,但如果是历史遗留改不动,就得把这个开关打开。

large_client_header_buffers也提一句。JWT 的长度取决于 claim 数量,一个只放用户 ID 和过期时间的 token 大概 200 字节,但如果塞了角色列表、权限列表、部门路径,轻松超过 2KB。代理默认的头缓冲区是 8K,单条头一般够用,但在多层代理叠加、或者请求带大量 Cookie 的场景下可能吃紧,超了会返回 400 或者 431,现象和鉴权失败很像。

4.3 服务间调用:token 最容易在这里丢

微服务架构下,A 服务收到用户请求,处理过程中要调 B 服务。这时候 token 存在 A 的请求上下文里,如果调用客户端没有配置请求拦截器,B 服务收到的请求是不带 token 的,直接 401。

这类问题的典型特征是:入口接口正常,但内部调用链上的某个服务报 401,而且报错的日志出现在 B 服务的日志里,A 服务的日志看起来一切正常。

解决方式是在调用客户端里加一个拦截器,从当前请求上下文取出 token 塞进新请求的头里:

public class TokenRelayInterceptor implements RequestInterceptor { @Override public void apply(RequestTemplate template) { // 从当前请求上下文获取,注意异步线程下可能为空 ServletRequestAttributes attrs = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes(); if (attrs == null) { return; // 异步或定时任务场景,没有请求上下文 } String token = attrs.getRequest().getHeader("Authorization"); if (token != null && !token.isBlank()) { template.header("Authorization", token); } } }

那个attrs == null的判断很重要。如果调用发生在异步线程或者定时任务里,请求上下文是空的,直接取值会抛空指针。我见过一个项目因为没做这个判断,导致定时任务里的一次内部调用直接崩了,报的错还跟鉴权毫无关系。

5. 问题速查表与踩坑心得

5.1 症状对照表,照着查就行

现象最可能的原因快速验证方式
浏览器控制台报跨域错误,Network 里 OPTIONS 返回 401预检请求被鉴权拦截检查鉴权逻辑是否放行 OPTIONS 方法
前端显示已携带 token,服务端日志无任何记录代理层丢弃了头,或请求未到达应用在应用入口打印全部头名
日志显示解析成功,业务代码取用户为空过滤器顺序问题,上下文被覆盖或清空打印过滤器执行顺序,确认是否重复注册
本地正常,测试环境全部 401密钥不一致,或配置未正确加载对比各环境密钥摘要
刚签发的 token 提示已过期exp 单位为毫秒,或签发/校验机器时钟偏差大打印 exp 数值位数和当前时间戳
登录接口本身返回 401白名单路径与实际路径不匹配打印实际请求路径与白名单配置
错误响应总是 401,看不到真实异常错误转发路径/error未放行检查白名单是否包含错误路径
偶发失败,刷新几次就好多实例配置不一致,或 token 被重复解析对比实例配置,检查过滤器是否注册两次
接口返回 200 但数据为空异常被全局处理器捕获后返回了成功状态检查全局异常处理对认证异常的处理分支

这张表我放在团队的排查文档首页,新同学遇到问题先对照一遍,能覆盖七八成的情况。剩下两三成通常是组合问题,需要按第 4 章的流程走一遍。

5.2 几条用代价换来的经验

第一条:异常处理器要放行认证异常,别一把梭全接住。很多项目为了统一响应格式,写了一个全局异常处理器捕获所有异常,返回统一结构。问题在于,认证和授权异常是在过滤器层抛出的,有些情况下全局处理器接不到,有些情况下接到了但返回了 200 状态码,前端拿到 200 就去解析数据,解析出一堆 null。我的做法是让认证类异常直接冒泡到安全框架处理,由它返回标准的 401 响应,只对业务异常做统一包装。

第二条:调试日志里永远打印"实际路径",而不是"配置的路径"。路径匹配问题占了白名单失效的绝大部分,打印配置值没用,因为配置值是你以为的,实际路径才是真相。这个日志建议常驻生产环境,成本就是一行字符串拼接。

第三条:token 里别塞太多东西。我见过把整个用户对象序列化进去的,token 长度接近 8KB。带来的问题是请求头过大,某些代理直接拒绝;而且 token 一旦签发就无法修改,用户改了昵称之后 token 里还是旧昵称,前端显示不一致。正确的做法是 token 里只放标识信息(用户 ID、会话 ID、版本号),其他信息由接口按需查询,用缓存扛住。

第四条:给鉴权失败留一条可追溯的线索。生产环境的 401 是最难排查的,因为你不可能为了查一个问题去打开全量日志。我的做法是在响应头里带一个短的事务 ID,同时在日志里记录这个 ID 对应的鉴权判定结果(是没带 token、签名错、还是过期)。用户反馈问题时,让他截图响应头,你拿 ID 去日志里一搜,三十秒定位。

第五条:密钥轮换要有过渡期。换了签名密钥之后,所有已签发的 token 立刻失效,用户全部掉线。稳妥的做法是同时支持新旧两把密钥校验,新 token 用新密钥签,旧的等自然过期后再下线旧密钥。这个过渡期的长度取访问令牌有效期的两倍比较合适,留足余量。

第六条:不要在鉴权逻辑里做耗时操作。有的实现会在每次请求时查一次数据库确认用户状态,这等于把无状态的优势全部还回去了。真要校验用户状态,走缓存,设置合理的过期时间,或者用前面说的版本号方案。

这套东西说到底没什么玄学,全是细节。JWT 本身是个很简单的标准,三段结构一目了然,难的是它嵌在整个请求链路里,任何一个环节的理解偏差都会以同一个表象暴露出来。我现在的习惯是,接到"鉴权进不去"的问题,先不看代码,先把链路画出来,标出每一层的输入输出,然后在能打日志的地方全部打上。多数时候链路画到一半,问题就已经浮出来了。

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

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

立即咨询