Skip to content
返回博客
安全

JWT 报 invalid signature:逐个排查真正的原因

JWT 签名验证失败、报 invalid signature 的真正原因:跨语言的密钥字节差异、.env 尾随换行、算法与密钥类型不匹配,以及怎么定位你碰上的那一个。附免费在线解码器。

14 分钟

JWT 报 invalid signature:逐个排查真正的原因

JWT 报 invalid signature 只意味着一件事:你这边算出来的签名,和 token 里带的那一串不相等。它不代表 token 过期,不代表用户权限不足,也不代表你的 JWT 库坏了。差异出在喂进 HMAC 的字节上,或者出在传进验证调用的那把公钥上,签发端和校验端对不上。

多数情况下,罪魁祸首是密钥材料,不是 token。用下面这棵树决定从哪里下手:

header 里的算法是哪个?
├─ HS256 / HS384 / HS512  → 几乎总是密钥问题
│    ├─ 签发端和校验端是不同语言? → 第 3 节
│    └─ 同一种语言,本地正常、生产失败? → 第 4 节
└─ RS256 / ES256 / PS256  → 几乎总是密钥格式不对,或者用错了钥
     └─ → 第 7 节

token 经过网关、代理,或者被复制粘贴过? → 第 6 节
过几个小时才报,或只在某一台机器上报?   → 第 8 节

下面每一节的结尾都给出可以直接跑的东西。最快的第一步是把 token 粘进 JWT 解码器 读出 alg 字段:上面那棵树有一半分支,在你知道 alg 的那一刻就塌缩掉了。

1. invalid signature 到底意味着什么

同一个失败,不同的库会打印不同的字符串。先在下面这份清单里找到你手上那条,确认自己没走错指南:

  • Node jsonwebtokenJsonWebTokenError: invalid signature
  • Python PyJWTInvalidSignatureError: Signature verification failed
  • Java jjwtSignatureException: JWT signature does not match locally computed signature. JWT validity cannot be asserted and should not be trusted.

这三条抛在同一条代码路径的同一个时刻。库取出 token 的前两段,用你交给它的密钥重新算一遍签名,再把结果和第三段逐字节比对。不相等,抛异常。

这个比对是精确比对,它不携带任何关于两个值差多远的信息。密钥差一个字节,和密钥完全用错,产生的错误信息一模一样。所以本指南剩下的篇幅讲的是怎么收缩输入空间,而不是怎么把错误信息读得更仔细。

还要注意这个错误抛出时有哪些事还没发生。claim 校验排在签名验证之后,所以 expnbfaudiss 此刻根本没被看过一眼。如果你遇到的是 JWT signature verification failed(签名验证失败),token 的内容与诊断无关,不过它依然是可读的,因为 JWT 是编码而非加密。解码 header 和 payload 一把钥匙都不需要;想看逐段拆解,见如何解码 JWT Token

header 里有两个字段决定你下一步走哪条路:alg 告诉你要找的是共享密钥还是密钥对,kid 告诉你签发端认为自己用的是哪一把钥匙。

2. 签名覆盖的是编码后的字符串,不是你的对象

后面所有排查都建立在这个心智模型上,而它恰好是最多开发者理解反的一环。

RFC 7515,也就是 JSON Web Signature 规范,把 JWS Signing Input 定义为这样一个 ASCII 字符串:

BASE64URL(UTF8(JWS Protected Header)) || '.' || BASE64URL(JWS Payload)

HMAC 算的就是这个字符串,不是你的 claims map,也不是任何被你的语言当成结构化数据的东西。下面是本文自始至终使用的 signing input,取自标准示例 payload:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ

后果很严重,而且团队反复栽在这里:任何一层只要把 payload 解码再重新编码,签名就废了。JSON 序列化不是规范化的(canonical)。map 在多数语言里往返一趟,键顺序就变了。空白字符凭空出现或消失。非 ASCII 字符被一个序列化器转义成 \uXXXX,被另一个原样输出。数字被重新格式化,1516239022 可能变回 1516239022.0。上述每一种都会产生不同的 base64url 字符串;signing input 一变,签名就跟着变。

我们见过的真实触发点:

  • API 网关解析 JWT,补一个租户 ID 进去,然后重新发出 token。
  • 日志或链路追踪中间件「规范化」请求头,把 Authorization 的值重写了。
  • 开发者为了看清内容把 token 美化了一下,又把美化后的版本粘了回去。

只要签发端和校验端之间有任何组件能改写 token,它就是第一嫌疑人。token 在传输途中是不透明字符串,安全的操作只有三种:存储、复制、比较。

3. 同一个密钥,不同的字节

排障文章很少讲这一条,可它正是「密钥明明一模一样,我 diff 过了」这类 bug 报告的答案。

HMAC 吃的不是字符串,是字节。而配置文件、密钥管理服务、环境变量里存的全是字符串。总得有人把字符串转成字节,而这一步的转换在各个 JWT 库之间并没有统一标准。两个服务可以持有逐字符完全相同的密钥,却算出不同的签名。

下面是实证,用第 2 节那个 signing input 在本机实算得出。密钥字符串是 36 个字符:

c2VjcmV0LWtleS0xMjM0NTY3ODkwYWJjZGVm
字节解释方式字节数密钥实际是什么算出的 HS256 签名
当作 UTF-8 文本36 字节就是这 36 个可见字符本身tUQobLFxHSIQqURPqGT59pkBnqQ95sZ0-JC1hE_4Zak
先做 base64 解码27 字节secret-key-1234567890abcdef53ISDuciq-ov8YF1Ezwy6zo6KUO-1tpwz2oW7gVPuIM

同一个密钥字符串,同一个算法,同一个 payload,签出来的两个值毫无共同之处。哪一端「错了」,哪一端就报 invalid signature,而无论怎么 diff 配置文件都查不出任何东西,因为配置文件本来就是一致的。

UTF-8 口径下的完整 token,你可以拿去复现:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.tUQobLFxHSIQqURPqGT59pkBnqQ95sZ0-JC1hE_4Zak

把它和上面那个密钥一起粘进 JWT 解码器,验证通过。先把密钥当 base64 解码再验,就不通过。

各个库如何把字符串变成密钥字节

这张表只收有文档依据的行为,范围刻意收窄,而最后一列比第一列更重要。

运行时 / 库字符串转字节的行为由谁决定
Node jsonwebtoken取字符串的 UTF-8 字节
Python PyJWT取字符串的 UTF-8 字节
Java jjwt,旧的 String 重载走平台 base64 codec,见 jwtk/jjwt#204
Go golang-jwt直接收 []byte,在调用处
.NET直接收 byte[],在调用处

Java 这一行是跨栈痛点的历史源头,措辞得说准。在旧版本 jjwt 里,signWith(SignatureAlgorithm, String) 及其同族方法会把 String 过一遍 base64 codec,而不是取它的原始字节,与此同时 byte[] 重载则原样使用你给的字节。于是共用一个密钥的 Node 服务和 Java 服务算不到一块去。这套 String API 自 jjwt 0.10 起已被废弃,现代写法是显式的:

SecretKey key = Keys.hmacShaKeyFor(secretBytes);

这不是「Java 处理 JWT 就这样」,而是某一个库的历史遗留重载;传 byte[] 的现代 jjwt 代码完全没有歧义。Node 侧的镜像报告是 auth0/node-jsonwebtoken#208,讲的是 Java 签出来的 token 到 Node 里验不过。PHP 的 firebase/php-jwt 也有同类报告,见 firebase/php-jwt#153,不过该库的字节处理细节我们没有亲自核实过,请把它当作线索,而不是结论。

Go 和 .NET 属于另一类。这两个库都不替你做决定,而是把 []byte / byte[] 这个参数递给你,然后退到一边。[]byte(secret)Encoding.UTF8.GetBytes(secret) 得到的是 UTF-8,Convert.FromBase64String(secret) 得到的是解码后的字节。真出了 bug,bug 就住在你自己的调用处。这其实是好消息:它在你自己的 diff 里看得见。

我的 JWT 密钥到底是 base64 还是 UTF-8

token 里没有任何标志位能告诉你。只能对字符串本身做推理:

  1. 它是否只用了 A–Z a–z 0–9 + / =(或者 -_)? 如果是,它有可能是 base64。含有空格、!# 的密钥则不可能是。
  2. 它的长度是不是 4 的倍数,或者结尾带 = padding? 两者都强烈暗示它在进来的路上被某个环节做过 base64 编码。
  3. 对它做 base64 解码,出来的字节合理吗? 拿到 Base64 解码器 里跑一遍。解出可读的 ASCII、或者恰好 32 个看起来随机的字节,说明它是 base64。解出乱码,说明这个字符串从来没被编码过。

c2VjcmV0LWtleS0xMjM0NTY3ODkwYWJjZGVm 这样的密钥三条测试全中,而这恰恰是它危险的地方:它是歧义的,两种读法都说得通。含有 -_ 的密钥则以更阴险的方式歧义,它们是合法的 base64url,却是非法的标准 base64。

推理推不出答案时,就两种都算一遍。把 signing input 拿到 HMAC 生成器 里跑两次 HMAC-SHA256,一次把密钥当文本,一次用解码后的字节,再把两个结果分别和 token 第三段比对。总有一个能对上,而它就告诉了你系统的哪一端是对的。

字符数不等于字节数

与之相关的陷阱是:要求以字节计,你却在数字符。RFC 7518 §3.2 规定 HMAC-SHA 的密钥下限用的是 bit,不是字符数,而编码过的文本是会膨胀的:

写法等效字节对 HS256(需 ≥256 bit)
32 个 hex 字符128 bit16 字节低于下限
32 个 base64 字符192 bit24 字节低于下限
32 字节随机数据256 bit32 字节达标(写成 hex 是 64 字符,写成含 padding 的 base64 是 44 字符)

一个「32 字符的密钥」,取决于字母表,可能是 128 bit,也可能是 256 bit。这与上面的字节解释问题是两码事,但咬的是同一批人,因为一支用字符数衡量密钥的团队,通常就是从没看过字节的那支团队。长度、编码怎么选、怎么轮换这些真正的选型规则,JWT 密钥生成器 的参考说明里已经讲透,这里不重复。

4. 密钥本身被污染了

两个服务在字节解释上已经达成一致,签名还是过不了。那就该查查每一端加载到的密钥,是不是你以为自己写下的那个了。环境变量这套管道,加一个字节的本事相当高明。

.env 里的尾随换行。 JWT_SECRET=abc 后面跟一个换行,在某些读取器里会被加载成 abc\n。多出一个字节,HMAC 的输出就彻底无关了。中间没有任何「有点像」的过渡可供察觉。

引号被当成了数据。 JWT_SECRET="abc" 在某些加载器里是 abc,在另一些里是 "abc",尤其是同一个文件被 shell source 与被库解析这两种情形。Docker Compose 的 env_file 和一个 .env 解析器就可能对同一份文件给出不同结论。

复制粘贴带进来的不可见字符。 从 Slack、wiki 或 PDF 里复制密钥,可能顺手拖进一个零宽空格(U+200B,字节 e2 80 8b)或不换行空格(U+00A0,字节 c2 a0)。两者在任何编辑器里都看不见,而两者都会改变 HMAC。

CI 与容器的加工。 密钥经过 shell 插值时,$ 会被展开、反斜杠会被吃掉。有的 CI 系统会 trim 掉首尾空白,有的不会。Kubernetes secret 在 manifest 里是 base64、在容器里是原文,这本身就是一个双重解码陷阱。

解法是别再盯着密钥看,改成去量它。在每一端打印长度和一个指纹,永远不要打印值本身:

printf '%s' "$JWT_SECRET" | wc -c
printf '%s' "$JWT_SECRET" | shasum -a 256 | cut -c1-16

在签发端和校验端各跑一次这两条命令,比对两边的输出。长度一致、指纹一致,说明密钥不是你的问题,回到第 3 节。长度比预期多 1,是尾随换行。多 2,是那对引号。

长度对不上、而你想看清里面究竟有什么时,在本地 shell 里对一个开发环境的密钥做 hex dump:

printf '%s' "$JWT_SECRET" | xxd

末尾的 0a 是换行符。首尾成对的 22 是一对引号字符。中间出现 c2 a0e2 80 8b,就是不可见字符那一类。不要在会把终端输出送往任何地方的机器上,对生产密钥跑这条命令。

在运行中的 Node 或 Python 进程里,等价的检查是:

const s = process.env.JWT_SECRET ?? '';
console.log(Buffer.byteLength(s, 'utf8'), JSON.stringify(s.slice(-3)));
import os
s = os.environ["JWT_SECRET"]
print(len(s), len(s.encode("utf-8")), repr(s[-3:]))

在 Python 里,len(s) 小于 len(s.encode("utf-8")),说明一个本该是纯 ASCII 的密钥里混进了非 ASCII 字符。

5. 算法和密钥类型对不上

alg header 和你传进去的密钥必须属于同一族。HS256 要的是共享密钥,即一串字节;RS256 和 ES256 要的是非对称密钥,即 PEM 或 JWK。接错线,你得到的失败可能是一个清晰的类型错误,也可能只是干巴巴一句 invalid signature,取决于库有多宽容。

常见的几种形态:

  • header 写着 HS256,校验端却把一个 PEM 公钥递给了库。有些库会直接对 PEM 文本做 HMAC,然后报签名不匹配。
  • header 写着 RS256,校验端却递过去 HMAC 的密钥字符串。
  • 校验端根本没传算法白名单,任由库从 alg 推断,于是签发端的一次配置漂移,就悄悄改变了校验端的行为。

最后这一条,是配置 bug 变成安全 bug 的地方,所以每一次验证调用都要显式固定算法:

jwt.verify(token, key, { algorithms: ['HS256'] });
jwt.decode(token, key, algorithms=["HS256"])

固定算法还有一个好处:把含糊的签名错误变成精确的错误。如果来的 token 是 alg: RS256 而你的白名单写的是 HS256,你会拿到一个把两个值都点名的显式算法错误。

这里值得划一条线。本节讲的是误配:你自己的两个组件互相不一致,全程没有攻击者参与。还有一种形态相同的失败是:攻击者把 algRS256 改成 HS256,然后拿你的公钥当 HMAC 密钥来签。那叫算法混淆,属于攻击而不是 bug,它和整套威胁模型一起写在 JWT 安全最佳实践 里。防御手段恰好也是显式白名单,所以哪怕你只是在追一个 bug,也该顺手把它加上。

6. token 在传输途中被改了

在怪罪密钥之前,先确认校验端收到的字符串和签发端产出的是同一个。JWT 在传输途中就是一个字符串,字符串会出的岔子它一个都躲不掉。

Bearer 前缀。 Authorization: Bearer eyJhbGci... 是一个 header 值,不是一个 token。切分时切错了地方,或者只切一次却留错了半边,你验的就成了 Bearer eyJhbGci... 或者一个空字符串。要有意识地剥掉它:

const token = req.headers.authorization?.replace(/^Bearer\s+/i, '').trim();

空白与换行。 从终端复制的 token 会折行。存进 YAML 的 token 会被折叠。第三段里嵌进去一个 \n,得到的是签名不匹配,而不是解析错误,因为 base64url 解码器往往会跳过空白,而字符串比对不会。

URL 编码。 作为 query 参数走过一趟的 token,回来时 . 可能变成了 %2E-_ 可能被某个过于热心的编码器转写了。解码一次,且只解一次。

被截断。 cookie 单个上限约 4 KB,而带几个 claim 的 RS256 token 经常超过这个数。被截断的 token 通常会在 base64 解码这一步就失败,但如果恰好切在 4 字符边界上,你拿到的就是一个看起来合法、签名却是错的 token。

两条命令能定这个案。一个格式正确的 JWT 恰好有两个点:

printf '%s' "$TOKEN" | tr -cd '.' | wc -c

而且每个字符都必须落在 base64url 字母表内,所以下面这条应该什么都不打印:

printf '%s' "$TOKEN" | tr -d 'A-Za-z0-9._-' | xxd

第二条命令的任何输出都直接点出了你的问题:3d 是本不该出现的 = padding,2b2f 是标准 base64 的 +/ 出现在了 base64url 期待 -_ 的位置,20 是一个混进来的空格。

7. RS256 与 ES256 特有的失败

非对称算法把密钥问题换成了密钥管理问题,失败形态也另成一套。

PKCS#1 与 PKCS#8。 这是同一把 RSA 密钥的两种容器格式,肉眼上靠 header 行里的一个单词就能分辨:

-----BEGIN RSA PRIVATE KEY-----      ← PKCS#1
-----BEGIN PRIVATE KEY-----          ← PKCS#8

各个库接受哪一种并不一致。某个库直接拒绝这种格式时,你会拿到清晰的错误;而它半解析成功时,你会拿到一个永远验不过的签名。别跟它较劲,转换掉:

openssl pkcs8 -topk8 -nocrypt -in pkcs1.pem -out pkcs8.pem

公私钥用反了。 拿公钥去签,或者拿私钥去验。道理上很显然,但当两个文件躺在同一个目录、名字只差四个字符时,做错太容易了。确认哪个是哪个:

openssl rsa -in key.pem -noout -text | head -1

私钥会以私钥的身份打印它的模数长度;公钥则会报错,除非你加上 -pubin

JWKS 与 kid 漂移。 用 JWKS 端点时,校验端是拿 token 的 kid 去密钥集里匹配来选钥的。这里有三件事会出岔子:签发端轮换了,而校验端缓存的 JWKS 是旧的;token 里没有 kid,校验端就取了集合里的第一把钥匙;或者两个环境发布了重叠的 kid 值。怀疑到这里时,重新拉一次 JWKS,确认 token header 里那个确切的 kid 在里面存在。

ES256 的签名编码。 ECDSA 签名是一对整数 rs,而序列化它们有两种方式。通用密码学栈往往输出 DER,一种变长的 ASN.1 结构。RFC 7518 §3.4 要求的却是 JOSE 形式:rs 各自补齐到固定长度后拼接,对 P-256 而言是 64 字节。把一个 DER 签名塞进 JWT,不只是值不对,长度都不一样。所以,第三段解不出恰好 64 字节的 ES256 token,是被某个跳过了这步转换的东西造出来的。

要判断问题出在密钥还是出在你的流水线上,用 JWT 编码器 独立地把同一个 payload 签一遍,再和你的服务产出的对比。签名相同,指向传输环节或 claim 处理;签名不同,指向密钥。

8. 看着像签名失败、其实不是的错误

有些确实是库自己贴错了标签,于是它们总被报进错误的 bug 分类里。

症状实际是什么该往哪里查
PyJWT ExpiredSignatureErrorexp 已过期。名字里写着 signature,起因却是一个 claim。主机间的时钟偏移,或者 TTL 设得太短
PyJWT ImmatureSignatureErrornbf 还在未来签发端的时钟快于校验端
Node TokenExpiredErrorexp 已过期同上
笼统的 401,没有细节框架把所有验证失败都塌缩成了一个响应打开库级别的错误日志
能用几分钟,然后开始失败是 token 过期,不是签名iatexp 对照两台主机的时钟
只对某一个受众失败audiss 不匹配校验端期望的受众列表

PyJWT 的命名是其中最突出的陷阱。ExpiredSignatureError 里带着 signature 这个词,却是在 claim 校验阶段抛出的,那时签名早已验证成功。拿这个错误串去搜索,会径直搜到签名排障的材料,然后几个小时就消失在了问题的错误分支里。

时钟偏移会制造最令人困惑的模式:间歇性失败,且与你代码里的任何东西都对不上。如果一台主机的时钟走快了,新签发的 token 一到就过不了 nbfiat 校验,而随着偏移变大,失败的位置还会游走。先在两台机器上比较 date -u。多数库都接受一个 leeway 参数,对于消除不掉的偏移它是正确的修法,对于一个真的坏掉的时钟它是错误的修法。

一条通则:如果失败与时间相关、与主机相关,或者与受众相关,那就不是签名问题。签名失败是确定性的。同一个 token 配同一把钥匙,永远以同样的方式失败。

9. 一条可复用的排查流程

按顺序跑。每一步要么找到 bug,要么砍掉一个分支,能提前停下就是目的。

  1. 解出 header。 把 token 粘进 JWT 解码器,记下 algkid。它决定了后面的一切,而且不需要任何密钥。
  2. 检查 token 的形态。 恰好两个点,只含 base64url 字符,没有 Bearer 前缀,没有空白。用第 6 节那两条命令。这一步排除传输损坏。
  3. 在验证调用上固定算法。 如果 alg 和你的白名单确实不一致,你现在拿到的是一个把两者都点名的显式错误,而不是一个笼统的错误。
  4. 在两端给密钥做指纹。 像第 4 节那样,在签发端和校验端各打印一次字节长度和截断的 SHA-256。数值不同,说明问题在管道上,你根本走不到第 5 步。
  5. 如果两端是不同语言,把字节口径定下来。 查第 3 节那张表,明确决定密钥是文本还是 base64,然后让两端都在代码里把这个决定写出来,而不是靠默认行为。
  6. 独立地把同一个 payload 重签一遍。 用你认为正确的密钥在 JWT 编码器 里签,把它的第三段和你 token 的第三段比对。对得上,说明签发端没问题,问题在校验端。
  7. 手工交叉验证 HMAC。 把 signing input 拿到 HMAC 生成器 里,用两种字节解释各跑一遍。哪一个和 token 对得上,就该改哪一端。

七步走完还是需要人帮忙的话,请把下面这些附上。多数 bug 报告卡住,就是因为漏掉了能决定答案的事实:

  • header 里的 alg 值,以及是否存在 kid
  • 两端(签发端与校验端)的语言、库和确切版本
  • 两端密钥的字节长度,以及其 SHA-256 的前 16 个 hex 字符(永远不要给密钥本身)
  • 密钥是以文本还是 base64 存储的,以及每一端各自怎么转换
  • 完整的 signing input。前两段并不敏感,反正拿到 token 的人本来就能读
  • 如果是 RS256 或 ES256:PEM 的 header 行,逐字照抄

这份清单能把一句无从回答的「我的 JWT signature does not match」变成一个别人真能解决的问题,通常一条回复就够。

常见问题

为什么同一个密钥在一种语言里能用,换一种就失败?

因为各个库对「字符串怎么变成密钥字节」的处理并不一致。Node jsonwebtoken 和 Python PyJWT 用 UTF-8;jjwt 旧的 String 重载走的是 base64 codec(见 jwtk/jjwt#204);Go 和 .NET 把这个决定留给你的调用处。同样的字符,不同的字节,签出来的 HMAC 自然也不同。

签名覆盖的是解码后的 payload,还是编码后的字符串?

编码后的字符串。RFC 7515 把 signing input 定义为字面的 ASCII 串 base64url(header) + "." + base64url(payload)。任何一层只要把 payload 反序列化再重新序列化,键顺序、空白或数字格式就会改变,从而产生不同的字符串,也就产生不同的签名。

我的密钥看起来像 base64,签名前该先解码吗?

只有在另一端也解码时才该。孤立地看,这个问题没有正确答案;要求是两端一致。先看这个字符串是否只用了 base64 字符、长度是否是 4 的倍数,然后在两端的代码里把选择写明确,而不是依赖默认行为。

.env 里一个尾随换行真能把签名弄坏?

能。HMAC 吃的是字节,abc\n 是四个字节,而 abc 是三个。得到的签名和正确的那个毫无共同之处。在两台主机上都跑一下 printf '%s' "$JWT_SECRET" | wc -c;长度比预期多 1,几乎总是这个原因。

怎么判断是密钥的问题还是算法的问题?

先读 header 里的 alg。如果它以 HS 开头,你需要的是共享密钥,传 PEM 一定失败。如果它以 RSPSES 开头,你需要的是密钥对,传密钥字符串一定失败。当 alg 与密钥类型属于同一族之后,剩下的失败就都是密钥内容的问题了。

为什么 jwt.io 说签名有效,我的服务器却拒绝?

因为在线工具和你的服务器可能对密钥采用了不同的字节解释:一个当 UTF-8 文本,另一个当 base64。工具校验用的是它自己推导出来的字节,不是你服务器推导出来的字节。另外,永远不要把生产密钥粘进第三方站点,用开发环境的密钥。

invalid signature 有可能是 token 过期造成的吗?

不可能。签名验证跑在 claim 校验之前,所以过期永远不会是原因。过期是单独浮现出来的:Node 里是 TokenExpiredError,PyJWT 里是 ExpiredSignatureError,后者的名字有误导性,因为签名验得好好的,只是 exp 这一关没过。

结语

签名不匹配几乎从来不是密码学的问题。HMAC-SHA256 是好的,RSA 是好的。出问题的是「字符串变成字节」的那道边界:一端是 base64 codec,另一端是 UTF-8;一个换行被配置加载器留了下来;一个 payload 被网关热心地重新序列化了。本文列的每一个原因,都落在这道边界上。

把字节口径显式写出来,别再依赖默认行为。在团队文档里写清楚共享密钥是以原文还是以 base64 存储的,并让每个服务都按这个声明去转换,而不是各自继承其库碰巧假设的那一种。跨多种语言的系统,就把密钥统一存成 hex 或 base64,并在每个调用处显式解码:每个服务一行代码,歧义就消失了。然后把第 4 节那个字节长度指纹加进健康检查,让下一次不匹配以启动告警的形式出现,而不是以生产环境的 401 出现。

标签: jwt authentication debugging hmac api-security