上个月替同事看一个内部系统的登录接口。前端拿到 401,抓包里 Authorization 头是 Bearer 加一长串、用 . 分成三节的字符,肉眼扫不出可读的词。他要的只有两件事:这串东西什么时候到期,role 里是不是 admin。我先把这两问拆开:「读得到什么」和「服务端认不认」是两条线,混在一起就排查不下去。
它是「编码 + 签名」,不是加密
前两节是 base64url 编码,不要密钥谁都能还原成 JSON;第三节才是签名,只有拿到对称密钥(HS 那一族)或对应公钥(RS 那一族)才验得了。这两层常被压成一句「加密了所以看不见」,其实相反:你看不见只因为还没解码,拿到串的人能看到全部字段。样例留下的三节长度并排抄出来:
Header : eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9
Payload : 264 个 base64url 字符
Signature : 43 个 base64url 字符
三段对照:字符数、字节数、能不能读
| 段 | base64url 字符数 | 解出后 | 能不能读出内容 | 本批样例里的值 |
|---|---|---|---|---|
| Header | 36 | 27 字节 / 27 字符 | 能,不需要密钥 | {"alg":"HS256","typ":"JWT"} |
| Payload | 264 | 198 字节 / 194 字符 | 能,但读出文本不等于可信 | iss、sub、aud、name、role、vip、iat、nbf、exp、jti |
| Signature | 43 | 32 字节 / 30 字符 | 读不出意图 | 样例密钥 your-256-bit-secret |
载荷那行 198 字节与 194 字符不是同一个数:name 的值是中文「张三」,UTF-8 下中文占的字节比字符多,role 则解出 ["admin","editor"]。手写 base64url 解码字符表一加就错位,这步我交给 JWT 解析工具:三段各报字节数与字符数,逐条解释注册声明,解析和验签都在浏览器里完成,页面不发网络请求。
iat、nbf、exp 都是 UTC 秒
- 有效样例的
iat与nbf都是 1792402758,换成 2026-10-19 09:39 UTC;两值相等,签发即生效。 - 同一段的
exp是 1861919999,等于 2028-12-31 23:59 UTC,相对脚本运行那次还剩 802.8944907407407 天。 - 已过期样例
iat1748593800 对应 2025-05-30 08:30 UTC,exp1748766600 对应 2025-06-01 08:30 UTC。 - 那段的
exp相对脚本运行时刻已过去 506.7513310185185 天,签名却依然是好的。
换算时别把秒当毫秒喂给日期函数;同一秒会给出 UTC 和本地两行,先确认读的是哪一行。想还原某一节内容可以用 Base64 编解码,把秒数换成可读日期用 时间戳转换。
验签通过和没过期是两件事
已过期的那段样例,签名段仍是 32 字节,用样例密钥 your-256-bit-secret 重算 HMAC,验签照样写「通过」。签名锁的是「签发到现在,前两节没被改过一个字符」,不含时间语义;服务端只验签不查 exp,很久前签的令牌照样能用。iss、aud 同理要服务端自己比对,工具不会替你查,权限判断更不在它职责里。
三种边界,长度先出卖了它们
| 样例 | alg | kid | 签名段字节数 | 那次运行打印的验签结果 |
|---|---|---|---|---|
| 有效 | HS256 | 无 | 32 | 通过(secret = your-256-bit-secret) |
| 已过期 | HS256 | 无 | 32 | 通过,但 exp 落在 2025-06-01 08:30 UTC |
alg=none | none | 无 | 0 | 没有签名可验 |
| 载荷不是 JSON | HS256 | 无 | 32 | 不匹配 |
| RS256 | RS256 | demo-key-1 | 256 | 通过(用页面内置公钥) |
alg=none 那行最值得盯:整串仍切成三节,最后一节解出 0 字节,等于没签名。风险在老的服务端库按令牌自报的算法验签——载荷改掉、算法写 none、签名抹空,校验就被绕过。载荷不是 JSON 的那种能解出原文「这是一段不是 JSON 的载荷文本」,但结构不成,验签报不匹配,脚本在那行后面直接打出重算的签名 4prUH45MQAmbRVmdqHYpEsc2OzZ5OM0TEgApoZOXywI,跟令牌自带那节一比就看出谁动过。
对称密钥的强度是能算出来的
HS 族同一把密钥签发和验证,密钥长度决定它扛不扛得住离线爆破。样例那把 your-256-bit-secret 只是演示占位:26 个小写字母取 8 位,候选空间 208827064576 种,每秒试 100 万次,58.01 小时扫完;字符表扩到大小写加数字 62 个、取 12 位,空间 3.2262667623979e+21 种,每秒 10 亿次也要 102421.2 年。前者是一个周末的量级,后者才勉强能指望。
几个当时没搞干净的问题
Q:这串字符是不是加密过的?谁能看到里面的内容?
没加密。前两节只是 base64url 编码,拿到的人都能还原成 JSON,它防篡改不防偷看。手机号、密码、证件号应该只在载荷里留 ID、服务端再查;键名撞上这些词时页面会提示,但拦住已签出的令牌不是它做的事。
Q:base64 和 base64url 差在哪?
字符表不同:base64url 把 + 和 / 换成 - 和 _,结尾补位符 = 也不写,一整节才能塞进 URL 和 header。常见示例载荷里那段 JSON 是 55 个字符,编成 base64url 后是原来的 1.345 倍。
Q:过期但验签通过的令牌,服务端到底认不认?
取决于服务端查不查。已过期那条就是典型组合:重算一致、验签通过,exp 却是 2025-06-01 08:30 UTC。有效期、签发者、受众、是否吊销各是各的检查项,漏一项就可能放行。
Q:HS256 和 RS256 该选哪个?
自己签自己验,HS 族省事,代价是每个验证方都握着同一把密钥;公钥要发给多个验证方时用 RS 或 ES 族,避免共享密钥外泄,代价是前面那 256 字节的签名。
回到同事那两个问题。到期时间是从 exp 那行 UTC 读出来的,不是从报错码猜的;role 是不是 admin,读出来只说明签发方当时这么写,接口认不认还得服务端说了算。我现在的取舍是载荷只放 ID 和一组角色名——多一个字段多一处泄露面,签名能防的只有篡改,不是偷看。