JWT 为什么失效?如何解码和调试一个 JSON Web Token
一个写着“invalid token”的 401,几乎从不意味着 token 是乱码,而是你和服务器之间某一项校验没过。与其攒一堆解码技巧,不如搞懂 JWT 到底是什么:一段签了名的明文——你随时能读,但只有验签之后才该信。想清楚这一点,每一种失效都会退化成一份简短、有序的排查清单。
请求打回来一个 401,body 里写着一句不痛不痒的 {"error":"invalid_token"}。token 就摆在 Authorization 头里——一长串看着毫无头绪、中间夹着两个点的字符串——你却完全不知道它哪儿出了问题。于是你开始猜:重新签发一个、把过期时间往后拨、换个密钥试试、把认证服务重启一遍。每一次尝试都近乎全凭运气,因为那句报错只告诉你有东西错了,没告诉你是哪个东西。
有一句话能终结这种瞎猜:那串东西既不是不透明的,也没有被加密。 此刻你就能读出它声称的每一个字节——不需要密钥,不需要服务器帮忙。JWT 是一段签了名的明文:payload 就明晃晃地摆在一层可逆编码后面,唯一没有密钥就伪造不出来的部分,是那段用来证明”谁写的”的签名(签名本身也是明文,随 token 一起传;保密的从来是密钥)。一旦你把这个事实吃进去,“我的 token 为什么失效”就不再是玄学,而变成一个简短、有序的问题:服务器要跑的那几项互相独立的校验里,是哪一项说了不?
这篇文章不是一份解码技巧清单。它先花几分钟讲清 JWT 到底是什么——它回答、并且严格分开的那两个问题——然后拿同一个 token,把它能被拒绝的每一种方式走一遍:从最无聊的(它过期了),到最隐蔽的(你 JSON 里的空白破坏了签名),再到最危险的(header 告诉服务器该信哪个算法,而 header 是攻击者能改的)。看到最后,你会有一份能对着任何 401 逐条跑的清单。
唯一的核心:解码和信任,是两个不同的问题
几乎所有 JWT 上的困惑,都能追回到把两个标准刻意分开的问题混为一谈:
- 这个 token 说了什么? —— 永远能回答。任何拿到 token 的人都能读出它的内容,不需要密钥。
- 我该不该信它说的? —— 只有拿着密钥才能回答——现实中就是持有那把密钥的一方,通常是收到请求的那台服务器。
JWT 的设计,就是让问题 1 轻而易举、让问题 2 严丝合缝。内容只是被编码了——一层谁都能还原的可逆变换——同时挂着一段密码学签名来回答问题 2。签名并不藏住任何东西,它不打乱 payload。它是一枚”防篡改的封印”:改动内容里的任何一个字符,封印就对不上了;但封印对不对得上,都不妨碍你读出内容。
所以”JWT 安全吗”是个陷阱问题。payload 的私密性差不多等于一张明信片——邮差能看——但它的防篡改性像一枚火漆封印:有人重写过,你会知道。把这个”两分”记牢。下面每一节其实都只落在一边:要么在读这个 token(问题 1),要么在讲服务器拒绝信任它的那些理由(问题 2)。
形状:三段 Base64URL,按点号切开
先把 token 拆开,别的都往后放。一个 compact JWT 恰好是三块,用两个点连起来:
header . payload . signature
前两块各自是一个 Base64URL 编码的 JSON 对象。按点号切开,把前两段解码,你面对的就是一段纯 JSON:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9 . eyJzdWIiOiJ1c2VyXzQyIiwibmFtZSI6IkFkYSIsImV4cCI6MTcwMDAwMDAwMH0 . 3Rf...
header 解出来大概长这样:{"alg":"HS256","typ":"JWT"}——类型,以及关键的签名算法。payload 解出来是你的一堆 claim,比如 {"sub":"user_42","name":"Ada","exp":1700000000}。signature 是一串原始字节,同样做了 Base64URL,它不是 JSON——别试着把它当文本读,它是某个哈希或签名运算的输出。
对”你随时能读”补一句限定:这话对紧凑格式的签名 token——也就是 JWS——成立,而你在几乎所有地方遇到的都是它。加密的 JWE token 也存在,它的紧凑格式是五段(四个点)而不是三段,payload 没有密钥就是真正不透明的,不在本文讨论范围内。所以如果一个”JWT”切不出三段可读内容,先怀疑它是 JWE——或者只是被截断、格式坏了——再继续尝试解码。
这里冒出第一个真正的坑,专咬那些手动解码、或者随手抓错工具的人:Base64URL 不是 Base64。 它是为了在 URL 和 HTTP 头里安全传输而设计的变体,和标准 Base64 有三处不同,而每一处都会悄无声息地把你的解码结果弄坏:
+换成了-,/换成了_——偏偏哈希里最容易出现的这两个字符,就是有差别的那两个。- 末尾的
=填充被去掉了。而标准 Base64 解码器往往要求有填充,缺了就直接抛错。 - 没有换行。
所以在浏览器里 atob(segment)、或者命令行里随手 base64 -d,面对一个完全合法的 token 通常会直接抛异常——- 和 _ 在标准字母表里根本是非法字符——而一个宽容到不报错的解码器,则会把错误的字节递给你。无论哪种,它们都是在用标准 Base64 去读一段 Base64URL。你得先把 -/_ 换回 +//,再补齐填充到 4 的倍数,标准解码器才肯配合。想直观看到这点,拿一段 segment 分别在 Base64 编解码工具里用 URL-safe 模式和标准模式各跑一遍——同一个输入,URL-safe 读得干干净净,标准模式要么噎住要么吐乱码。这一处不匹配,是”我自己解个码看看”最常翻车、也最容易把人带进错误排查方向的原因。(Base64URL 和标准 Base64 为什么一个能解、一个报错,以及 Base64 解不出来的其他原因,另有一篇专门讲。)
真正让你被拒的那些 claim
假设你已经把 payload 干净地解出来了。大多数 invalid_token 拒绝其实根本和密码学无关——而是服务器检查了某个 claim,不满意。下面是 RFC 7519 里那几个注册 claim,其中有几个是最容易触发拒绝的:
exp(过期) —— 头号原因,遥遥领先。它是一个 Unix 时间戳,单位是秒;过了那一刻,token 就必须被拒。几乎每个库都会自动强制这条,所以一个过期 token 往往在你代码看到它之前就已经被毙了。nbf(not before,尚未生效) —— 镜像的另一面。token 还没生效。你会撞上它,要么因为 token 是为将来签发的,要么——更常见——因为签发方那台机器的时钟比验证方走得快。iat(issued at,签发时间) —— 什么时候创建的。它本身通常不触发拒绝,但它是你推理”token 有多老”和”时钟漂了多少”的锚点。aud(audience,受众) —— 这个 token 是给谁的。如果你的 API 是https://api.example.com,而 token 的aud写的是另一个服务,一个正确的验证方即便签名完美无缺也会拒绝它。给 A 服务签的 token 被拿去 B 服务重放,正是aud存在的意义:堵住这种事。OIDC 里一个很具体的版本:把 ID token(它的aud是你的客户端应用)发给一个只认 access token(aud是那个 API)的接口——token 本身千真万确,只是用错了地方:签名对,受众不对。iss(issuer,签发方) —— 谁签的。验证方会拿它去对一份可信签发方的白名单,并据此找到该签发方公布的密钥集(它的 JWKS 端点)。至于用密钥集里具体哪一把,是由 header 里的kid决定的,而不是iss。
藏在所有时间类 claim 里的那个坑:exp、nbf、iat 是 epoch 秒,不是毫秒。 JavaScript 的 Date.now() 给的是毫秒,所以一段忘了除以 1000 的手写比较,会把每个 token 都读成大约五万四千年后才过期——或者反过来,把每个刚签发的 token 都当成早已作废,取决于你哪边搞反了。而且因为它就是个 1700000000 这样的裸整数,你的眼睛一眼看不出这 token 是上周就死了、还是明年才到期。这恰恰是工具能帮上大忙的地方:把 token 粘进 JWT 解析器,exp、iat、nbf 会渲染成真正可读的日期,悬停还能看到绝对时间——于是”它过期了吗”和”两台服务器时钟对得上吗”从你脑子里要做的算术,变成了你眼睛能看见的事。时钟偏差尤其如此:在裸 epoch 整数里几乎隐形,一并排成日期就一目了然。
签名:它是用来”验”的,不能被”解”
这一节,“两个问题”的分野变得锋利起来。有人说”把签名解码出来”——但签名并不是一段你能反向还原成可读内容的编码信息。它是把 header 和 payload 送进一个带密钥的运算得到的输出,你唯一能对它做的事,就是重新算一遍、看对不对得上。签名是拿来验的;你永远不会去解一个签名。
那个运算具体是什么,取决于 header 里的 alg,而这条分界线沿着一道很值得理解的断层走:
- HS256 / HS384 / HS512 —— 对称(HMAC)。 签名是
HMAC-SHA256(secret, header.payload)。同一把共享密钥既签又验。简单、快,却有一条锋利的边:任何能验证 HS256 token 的人,也能伪造一个,因为验和签用的是同一把钥匙。这里的 HMAC 就是你给 webhook 签名、给 API 请求签名时会用到的那个 HMAC-SHA256 原语——JWT 签名不过是那个摘要套上了一层标准化的输入格式。 - RS256 / ES256 / PS256 / EdDSA —— 非对称。 签发方用私钥签,其他所有人用配对的公钥验。这正是大型系统偏爱 RS256 的真正理由:认证服务器攥着私钥,别人谁也签不出 token,而下游一百个服务各自拿一把随便分发的公钥就能验证,却依然伪造不出任何东西。想亲手感受这对密钥的形状,用 RSA 密钥生成器生成一对——私钥签,公钥验,它俩不能互换。
非对称这一侧还有一个常被忽略、却造成大量真实故障的环节。验证方通常并不直接攥着公钥——它去拉取签发方的 JWKS(一组公布出来的密钥),再由 header 里的 kid 指出这个 token 是用其中哪一把签的。签发方会定期轮换这些密钥。如果你的服务缓存了 JWKS,而对方刚好轮换了,新 token 上的 kid 就可能指向一把你还没拉到的密钥——于是所有 token 会在同一刻集体验不过,尽管 token 本身一点问题都没有。当整批 token 同时失效时,先怀疑密钥轮换 + JWKS 缓存过期,再去怀疑 token。
这一节的要害、也是整个重点:解出了 payload,证明不了任何关于信任的事。 你可以把一个 token 的 claim 读得清清楚楚,而签名照样一文不值——密钥错了、根本没给密钥、内容被篡改过。一个好的解析器会诚实地守住这个区别。当你不带密钥粘进一个 token,JWT 解析器会把状态显示成 “Decoded, not verified”(已解码,未验证)——故意不是绿色、不是对勾——就为了不让你把”我能读它”错当成”我能信它”。只有当你提供了配对的密钥或公钥,它才会挪到 “Signature verified”(签名已验证)。这种在标签上的克制,正是”两个问题”这一思路的可视化。
隐蔽的那个:签名盖的是精确的字节,不是那份 JSON
这一点连懂上面全部内容的人都会栽,所以值得单开一段。签名并不是算在”作为数据结构的 header 和 payload”上的——它算在前两段精确的 Base64URL 文本上,一个字符一个字符,连中间那个点都算进去。签名的输入,字面上就是 ASCII 字符串 base64url(header) + "." + base64url(payload)。
这意味着:如果你把 payload 解出来、美化一下 JSON、再编码回去,然后把旧签名粘回去——哪怕你语义上一个字都没改,这个 token 现在也失效了。重排键的顺序、加空白、或者一个把 {"a":1} 重新序列化成 { "a": 1 } 的解码器,产出的都是不同的字节,而不同的字节意味着不同的签名。这是刻意设计的——正是它让封印能防篡改——但它解释了一种真正让人摸不着头脑的失效:“我只是重新排了个版,它就验不过了。“你没改意思,可你改了字节,而签名从头到尾只承诺过关于字节的事。这也是为什么你没法手改一个 JWT:要诚实地改一个 claim,你必须重新签名,而这需要密钥。(这正是解析器 sign 模式的用途——改 JSON、给密钥,得到一个货真价实重新签过的 token,而不是一个拼接坏了的东西。)
危险的那个:header 在告诉服务器该怎么信任它
现在到了把一篇调试文章变成安全文章的部分,也是这个系列往下走的引子。再看一眼 alg 待在哪:在 header 里。header 是 token 的一部分。而 token 是由发送它的人提供的。 那条”该如何验证信任”的指令,恰恰坐在那个信任本身都存疑的东西内部。
两个经典攻击直接从这里掉出来:
alg: none。 规范定义了一种”未加保护”的 JWT,完全没有签名。一个天真的验证方,读了 header 里的alg就照做,会看到"alg":"none"、于是完全跳过签名检查,接受一个攻击者手写的 token。修法是:服务器必须由自己的配置来决定哪些算法可接受——绝不能把alg当成来自 token 的指令。- RS256 → HS256 混淆。 如果一个服务器”
alg说啥就验啥”、手里又攥着一把 RSA 公钥,攻击者可以造一个把alg改成HS256的 token,并用那把公钥当作 HMAC 密钥去签。公钥是公开的——所以攻击者伪造所需的一切都有了。而库被告知要做 HMAC,就老老实实验过了。修法还是那句:在服务器端把期望的算法钉死,别让 token 自己挑。
把分寸摆正:主流的库这些年一直在防这两种攻击。它们默认拒绝 alg: none,除非你特意去允许;它们也要求你事先写死接受哪些算法,而不是听信 token 里的 alg。所以这不是要你对每个 JWT 都草木皆兵——而是要你警惕手写的验证逻辑、老旧到没人维护的库、以及任何把 token 自己的 alg 塞进”可接受算法列表”的配置。
这条主线和整篇文章是同一条。token 可以声称任何东西——alg、exp、sub、admin: true——因为 payload 和 header 只是谁都能写的明文。安全性完全来自服务器拒绝把 token 自己的 claim 当指令、以及一次由它自己掌控的签名校验。这道缝正是本系列下一篇要撬开的地方:为什么你绝不能信任一个还没验过的 payload,以及这份信任被绕过的每一种方式。 上面讲的全是”如何读一个 token”;那一篇讲的是”如何不被一个 token 骗到”。
面对一个真实 401 时的清单
下次一个 token 被拒,别闭着眼重签。按顺序跑这几步——几乎每种情况都能被它隔离出来:
- 先把它读出来。 把 token 粘进 JWT 解析器,看解码后的 header 和 payload。别猜里面是什么——看着它。多数时候答案就在某个 claim 里。
- 查
exp(还有nbf)。 它们是不是在过去/未来?把它们当日期读,不是当整数,并且对一下签发方和验证方的时钟——偏差是那个沉默的元凶。记住:是秒,不是毫秒。 - 查
aud和iss。 audience 写的是你这个服务吗?issuer 是你服务器信任的那个吗?签名再完美,这两关照样能拦下它。 - 查传输环节。 是不是多了个
Bearer前缀、尾部空白、或者复制时混进头里的一个换行?在工具里验得过的 token,可能就因为一个看不见的字符而在链路上失败。 - 有意识地去验签。 提供配对的密钥(HS)或公钥(RS/ES/EdDSA),确认它从”Decoded, not verified”挪到了”Signature verified”。如果验不过,常见原因是:密钥错了、header 里的
alg和你的密钥家族对不上、或者签名之后字节被改过(见上面那个重排版的坑)。在 RS/ES/EdDSA 上再加一个嫌疑对象:签名密钥轮换了、而 JWKS 缓存是旧的——如果 header 里的kid已经对应不到你手上的任何一把密钥,验签失败的原因就和这个 token 本身毫无关系。 - 确认
alg是你预期的——而且是你服务器把它钉死,而不是从 token 里读。如果它写着none、或者在你预期 RSA 的地方写着某个 HMAC 算法,那不是一个绕过去就行的 bug;那是一面红旗。
有两条规矩能让这六步都立得住。读一个 token 不需要密钥,信一个 token 永远需要——把这两件事分开,一半的困惑就蒸发了。以及,因为 payload 谁都能读,永远别往里塞机密——JWT 是签名,不是加密,把每一个 claim 都当作公开的来对待。RS/ES 的验证公钥多数时候来自签发方的 JWKS,但有时对方会直接给你一张 X.509 证书——这时你可以用证书解析器读出那张证书的主体和有效期。(读证书告诉你的是它里面装了什么,而不是”它的签发方就该被你信任”——那一步仍然要靠你自己的白名单。)但一切的起点都一样:别再把 token 当成一个只能重签的不透明黑块,开始读那段从头到尾就摆在你面前的明文。