JWT 到底装了什么?贴一个 token 把 3 段拆开,实测 alg=none 的坑
一个 224 字符的 JWT 贴进去,页面当场把它拆成三段:27 字节的 header、107 字节的 payload、32 字节的签名。
填上密钥点一下按钮,页面自己算出的 HMAC-SHA256 和 token 里带的签名,32 个字节逐字节对得上。

在线案例与源码:https://atomgit.com/deli007/demo_park/tree/main/codearts-jwt-decoder
生成物只有一个index.html,纯前端、零依赖、不联网,token 和密钥都不出浏览器。
一、为什么值得拆开看:三段里藏着三个坑
JWT 天天在用,真正容易出事的地方只有三处:payload 是明文、过期时间没人查、算法名写在 token 自己身上。
我把这三件事都做成了能点、能改、能当场复现的演示。
二、准备环境:进入码道 Web,把需求写成一段话
浏览器打开 https://devcloud.cn-north-4.huaweicloud.com/chat/home ,在对话框里把要做的应用说清楚,就能拿到一个能跑的单文件应用。
本文这个解剖台就是这么来的,不用装软件,登录华为云账号即可使用。
三、JWT 不神秘,就是三块拼起来的一段字符串
JWT 是三个 base64url 串,中间用两个点号连起来。
第一段叫 header,说明用什么算法签名。第二段叫 payload,装业务声明。第三段是签名,前两段拼起来的哈希值。
我把示例 token 拆开量了一遍:三段分别是 36、143、43 个字符。
解码之后正好是 27、107、32 字节。
这个 4 比 3 的比例不是巧合,base64 用 4 个字符表示 3 个字节。
四、三段逐字节看,长度都是算得出来的
页面把每段的细节都摊开了,实测数字是这样:
| 段 | 解码后内容 | base64url 长度 | 解码字节数 | 省略的填充符 |
|---|---|---|---|---|
| header | {"alg":"HS256","typ":"JWT"} |
36 字符 | 27 字节 | 0 个 |
| payload | sub / name / iat / nbf / exp / admin | 143 字符 | 107 字节 | 1 个 |
| signature | HMAC-SHA256 的二进制结果 | 43 字符 | 32 字节 | 1 个 |
base64url 和常见的 base64 差两处:+ 换成 -、/ 换成 _,末尾的 = 直接砍掉。
砍掉填充符之后长度不再被 4 整除,解码前得自己补回来。
页面里有一条断言专门盯这件事:单字节的 YQ 省略了 2 个填充符,补成 YQ== 才能解出 1 个字节。
header 恰好是 36 个字符,能被 4 整除,所以一个填充符都不用补。
五、验签要真算一遍,不是"看着像"
我去掉了所有"看起来对"的判断,只用浏览器自带的 Web Crypto API 算真签名。
密钥进 crypto.subtle.importKey,前两段拼起来送进 crypto.subtle.sign,出来 32 个字节。
拿这 32 个字节和 token 第三段解出来的 32 个字节并排对齐,逐字节比。

这一步没有任何网络请求,密钥和 token 全程留在本地。
顺手量了一下开销:同样的验签跑 2000 次,总共 8.5 毫秒,平均一次 4.3 微秒。
解析 10000 次 header 加 payload 也只要 34.6 毫秒。
也就是说校验对接口延迟的影响可以忽略,该验的都得验。
六、改一个字节,签名立刻对不上
第三段是哈希,payload 动一个字符,哈希就全变。
我把示例里的 name 改成 Mallory、admin 改成 true,签名保持原样,再验一次。
页面算出的签名是 4122d31f88c33cc4…,token 里带的是 6ae58e1995d0340f…,32 个字节里没有一个相同。

红色高亮是我特意加的:只给一句"验签失败"没说服力,红色字节数会自己说明问题。
七、alg=none 才是真坑:算法写在 token 自己身上
签名的算法名写在 header 里,这个设计天生别扭。
攻击者把 header 改成 {"alg":"none"},第三段留空,payload 想写什么写什么。
验证方如果照着 header 里的 alg 去选算法,看到 none 就跳过验签,伪造的 token 直接过关。
正确做法是先定一份算法白名单,header 里的 alg 不在白名单里就拒。
页面把两条路都摆出来:白名单 {HS256} 命中不了 none,拒绝;信 header 的老写法,none 直接放行。
同一份输入、两种判断,一个拦得住,一个拦不住。
八、过期检查,别把 exp 当装饰
payload 里的 iat、nbf、exp 都是 Unix 秒,不换算根本看不出问题。
页面把它们换算成本地时间,再和当前时间比。
示例里的过期 token,exp 是 1791976160,换算过来是 2026 年 10 月 14 日 19 时 09 分。

同一屏里还有一条正常 token,exp 写着还有 4 天 23 小时有效,用的是绿色。
只解码 payload 不判 exp,等于发一张永久通行证。
九、我踩的坑:第一版自检自己都不信
页面底部有 12 条断言,加载时自动跑,结果直接渲染出来,这是为了让我自己也没法糊弄。
第一版交上来,12 条里挂了 1 条。
挂的偏偏是最关键的第 6 条:正常 token 的 HMAC 验签。
原因是它把算出来的签名转成十六进制,再和 token 里 base64url 的签名串直接比,两个口径不同,永远不相等。
我用 Python 的 hmac 模块独立算了一遍,得到的 base64url 签名 auWOGZXQNA_1Nk9I4L6OnNHIokk39bMftkNHtp3oHsM 和 token 里带的完全一致,说明 token 本身没问题,是断言写错了。
改成同一个口径再比,12 条全过。

顺手还修了时间卡片里重复显示的一行状态,同一个"有效"印两遍,看着像有两条结论。
十、实测数据汇总
| 项目 | 实测值 | 说明 |
|---|---|---|
| token 总长 | 224 字符 | 36 + 1 + 143 + 1 + 43 |
| header | 27 字节 | 解出 alg 与 typ |
| payload | 107 字节 | 6 个声明字段 |
| signature | 32 字节 | HMAC-SHA256 输出 |
| HMAC 验签 2000 次 | 8.5 毫秒 | 平均 4.3 微秒一次 |
| 解码 + JSON 解析 10000 次 | 34.6 毫秒 | 平均 3.5 微秒一次 |
| 篡改 token 相同字节 | 0 / 32 | 整段签名失效 |
| 页面自检 | 12 / 12 通过 | 加载时自动执行 |
数字都在页面里能复现,刷新一次就重跑一遍。
十一、本地复现
生成物只有一个 index.html,纯前端、零依赖、不联网。
存到本地双击就能打开,file:// 下 Web Crypto 也能正常用。
想用静态服务也行:python -m http.server 8000,然后访问 http://localhost:8000 。
替换成自己的 token 时记得把密钥一起改掉,HS256 的签名是密钥相关的。
十二、用码道做这个小工具的体会
码道有三种使用方式:WebUI(浏览器对话)、TUI(终端命令行)和桌面 IDE(IDE 插件)。本文用 WebUI 版演示。
浏览器打开 https://devcloud.cn-north-4.huaweicloud.com/chat/home ,把需求写成一段话发出去,就能拿到一个能跑的 index.html。
这次最值钱的一条需求是"页面里要有自检脚本,把每条断言的结果渲染出来"。
正因为有这条,第一版那个十六进制比 base64url 的错才藏不住。
另一个体会是"要求展示实测值,而不是结论":验签结果旁边同时给出本页计算值和 token 携带值,读者一眼就能自己数相同字节数。
不足也说一句:这个工具只支持 HS256,RS256 这类非对称算法还没做;密钥是手填的,不能当生产环境的验签服务用。
它更适合当作理解 JWT 的解剖台,而不是替代后端的鉴权库。
- 点赞
- 收藏
- 关注作者
评论(0)