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
jsonwebtoken:JsonWebTokenError: invalid signature - Python
PyJWT:InvalidSignatureError: Signature verification failed - Java
jjwt:SignatureException: JWT signature does not match locally computed signature. JWT validity cannot be asserted and should not be trusted.
这三条抛在同一条代码路径的同一个时刻。库取出 token 的前两段,用你交给它的密钥重新算一遍签名,再把结果和第三段逐字节比对。不相等,抛异常。
这个比对是精确比对,它不携带任何关于两个值差多远的信息。密钥差一个字节,和密钥完全用错,产生的错误信息一模一样。所以本指南剩下的篇幅讲的是怎么收缩输入空间,而不是怎么把错误信息读得更仔细。
还要注意这个错误抛出时有哪些事还没发生。claim 校验排在签名验证之后,所以 exp、nbf、aud、iss 此刻根本没被看过一眼。如果你遇到的是 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-1234567890abcdef | 53ISDuciq-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 里没有任何标志位能告诉你。只能对字符串本身做推理:
- 它是否只用了
A–Z a–z 0–9 + / =(或者-和_)? 如果是,它有可能是 base64。含有空格、!或#的密钥则不可能是。 - 它的长度是不是 4 的倍数,或者结尾带
=padding? 两者都强烈暗示它在进来的路上被某个环节做过 base64 编码。 - 对它做 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 bit | 16 字节 | 低于下限 |
| 32 个 base64 字符 | 192 bit | 24 字节 | 低于下限 |
| 32 字节随机数据 | 256 bit | 32 字节 | 达标(写成 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 a0 或 e2 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,你会拿到一个把两个值都点名的显式算法错误。
这里值得划一条线。本节讲的是误配:你自己的两个组件互相不一致,全程没有攻击者参与。还有一种形态相同的失败是:攻击者把 alg 从 RS256 改成 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,2b 或 2f 是标准 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 签名是一对整数 r 和 s,而序列化它们有两种方式。通用密码学栈往往输出 DER,一种变长的 ASN.1 结构。RFC 7518 §3.4 要求的却是 JOSE 形式:r 和 s 各自补齐到固定长度后拼接,对 P-256 而言是 64 字节。把一个 DER 签名塞进 JWT,不只是值不对,长度都不一样。所以,第三段解不出恰好 64 字节的 ES256 token,是被某个跳过了这步转换的东西造出来的。
要判断问题出在密钥还是出在你的流水线上,用 JWT 编码器 独立地把同一个 payload 签一遍,再和你的服务产出的对比。签名相同,指向传输环节或 claim 处理;签名不同,指向密钥。
8. 看着像签名失败、其实不是的错误
有些确实是库自己贴错了标签,于是它们总被报进错误的 bug 分类里。
| 症状 | 实际是什么 | 该往哪里查 |
|---|---|---|
PyJWT ExpiredSignatureError | exp 已过期。名字里写着 signature,起因却是一个 claim。 | 主机间的时钟偏移,或者 TTL 设得太短 |
PyJWT ImmatureSignatureError | nbf 还在未来 | 签发端的时钟快于校验端 |
Node TokenExpiredError | exp 已过期 | 同上 |
| 笼统的 401,没有细节 | 框架把所有验证失败都塌缩成了一个响应 | 打开库级别的错误日志 |
| 能用几分钟,然后开始失败 | 是 token 过期,不是签名 | 拿 iat 和 exp 对照两台主机的时钟 |
| 只对某一个受众失败 | aud 或 iss 不匹配 | 校验端期望的受众列表 |
PyJWT 的命名是其中最突出的陷阱。ExpiredSignatureError 里带着 signature 这个词,却是在 claim 校验阶段抛出的,那时签名早已验证成功。拿这个错误串去搜索,会径直搜到签名排障的材料,然后几个小时就消失在了问题的错误分支里。
时钟偏移会制造最令人困惑的模式:间歇性失败,且与你代码里的任何东西都对不上。如果一台主机的时钟走快了,新签发的 token 一到就过不了 nbf 或 iat 校验,而随着偏移变大,失败的位置还会游走。先在两台机器上比较 date -u。多数库都接受一个 leeway 参数,对于消除不掉的偏移它是正确的修法,对于一个真的坏掉的时钟它是错误的修法。
一条通则:如果失败与时间相关、与主机相关,或者与受众相关,那就不是签名问题。签名失败是确定性的。同一个 token 配同一把钥匙,永远以同样的方式失败。
9. 一条可复用的排查流程
按顺序跑。每一步要么找到 bug,要么砍掉一个分支,能提前停下就是目的。
- 解出 header。 把 token 粘进 JWT 解码器,记下
alg和kid。它决定了后面的一切,而且不需要任何密钥。 - 检查 token 的形态。 恰好两个点,只含 base64url 字符,没有
Bearer前缀,没有空白。用第 6 节那两条命令。这一步排除传输损坏。 - 在验证调用上固定算法。 如果
alg和你的白名单确实不一致,你现在拿到的是一个把两者都点名的显式错误,而不是一个笼统的错误。 - 在两端给密钥做指纹。 像第 4 节那样,在签发端和校验端各打印一次字节长度和截断的 SHA-256。数值不同,说明问题在管道上,你根本走不到第 5 步。
- 如果两端是不同语言,把字节口径定下来。 查第 3 节那张表,明确决定密钥是文本还是 base64,然后让两端都在代码里把这个决定写出来,而不是靠默认行为。
- 独立地把同一个 payload 重签一遍。 用你认为正确的密钥在 JWT 编码器 里签,把它的第三段和你 token 的第三段比对。对得上,说明签发端没问题,问题在校验端。
- 手工交叉验证 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 一定失败。如果它以 RS、PS 或 ES 开头,你需要的是密钥对,传密钥字符串一定失败。当 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 出现。