004、JWT结构详解:Header、Payload、Signature与编解码


昨天排查线上问题,一个微服务间的接口突然返回403。日志里只有一句“Invalid token”,抓包看到Authorization头里明明带着Token,格式也没错。最后发现是某个服务偷偷升级了JWT库,签名算法默认配置变了。这种问题不深入理解JWT的结构,根本无从下手。

今天我们就拆开一个JWT,看看它到底是怎么组装起来的。

一、先看一个完整的JWT长什么样

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c

注意中间的两个点,把字符串分成了三段。这就是JWT的标准格式:Header.Payload.Signature。每段都是Base64Url编码后的JSON,别被那一长串字符吓到,解码看看就明白了。

二、Header:元数据声明

第一段解码后是这样的:

{
  "alg": "HS256",
  "typ": "JWT"
}

alg指定签名算法,常见的有HS256(HMAC SHA-256)、RS256(RSA SHA-256)。这里有个坑:如果你用非对称加密(比如RS256),别在客户端存私钥——私钥只能放在签发服务端。

typ通常就是"JWT",有些老库会检查这个字段。我遇到过把typ写成"JWT "(多一个空格)导致验证失败的案例。

三、Payload:实际携带的数据

第二段解码后:

{
  "sub": "1234567890",
  "name": "John Doe",
  "iat": 1516239022
}

这些字段叫Claim(声明)。分三类:

注册声明(建议但不强制使用):

  • iss(签发者)
  • exp(过期时间戳,这个一定要设!)
  • sub(主题)
  • aud(接收方)

公共声明:可以自定义,但最好避免和已注册声明冲突。我习惯加个"uid"放用户ID,"role"放角色。

私有声明:业务自定义字段。注意别塞敏感数据(比如密码),因为Payload只是Base64编码,不是加密。见过有人把手机号明文放进去,被中间人抓包直接解码出来。

时间戳字段(iat、exp、nbf)都是Unix时间戳,单位是秒。这里容易踩坑:有些语言库默认用毫秒,生成和验证时单位要对齐。

四、Signature:防篡改的关键

签名是这样生成的(伪代码):

HMACSHA256(
  base64UrlEncode(header) + "." + base64UrlEncode(payload),
  secret
)

注意拼接的是编码后的字符串,不是解码后的JSON对象。验证时服务端用同样的算法重新计算一次,和传过来的第三段对比。不一致就说明Token被改过。

如果是RS256非对称加密,要用私钥签名、公钥验证。部署时千万别把公钥私钥搞反——线上出现过用公钥签名导致所有验证都失败的故障。

五、编解码实战细节

Base64Url和标准Base64的区别:把+换成-/换成_,去掉末尾的=。有些语言的Base64库需要手动处理这个转换。

解码时先分段:

# 实际代码要处理异常,这里简写
header_b64, payload_b64, signature_b64 = token.split('.')

然后分别Base64Url解码。注意Payload解码后是JSON字符串,要解析成对象才能用。

自己写编解码练习一下:

import base64
import json

# 模拟生成
header = {"alg": "HS256", "typ": "JWT"}
payload = {"user_id": 123, "exp": 1741000000}

header_b64 = base64.urlsafe_b64encode(json.dumps(header).encode()).rstrip(b'=')
payload_b64 = base64.urlsafe_b64encode(json.dumps(payload).encode()).rstrip(b'=')
# 签名部分省略...

调试时可以在jwt.io这个网站粘贴Token直接解码查看(注意别贴生产环境的真实Token)。

六、几个容易栽跟头的地方

  1. 算法不一致问题:Header里写HS256,验证时用RS256,肯定失败。建议服务端强制校验alg字段,防止算法替换攻击。

  2. 时间漂移:多台服务器时间不同步,导致exp验证不准。最好留点余量(比如给5分钟容忍)。

  3. Base64编码陷阱:有些库的Base64Url实现会补等号,有些不会。跨语言传递时先统一格式。

  4. Claim名称冲突:自定义字段别起名叫"iss"、"exp"这些保留字。曾经有同事在Payload里加了个"exp"字段想表示“过期原因”,结果直接导致Token被判定为已过期。

  5. 签名密钥管理:HS256的密钥要足够长(建议32字节以上),定期轮换。见过用"secret"当密钥的,暴力破解几分钟就出来了。

最后说点经验

看懂了结构,开头那个403问题就好解决了:新库默认用了RS256,而旧Token是HS256签名的。要么回滚库版本,要么做兼容处理——验证时先读Header里的alg字段,再选对应算法。

实际开发中,我建议在Payload里最少放三个字段:用户ID(uid)、过期时间(exp)、签发时间(iat)。角色权限这类经常变的数据,最好别放Token里,而是查数据库或缓存。Token本身应该尽量轻量,毕竟每次请求都要带在头上。

下次遇到JWT验证失败,别急着抓瞎。按这个顺序查:先看签名是否一致(算法/密钥问题),再看时间是否有效(时钟同步问题),最后看Claim是否符合预期(业务逻辑问题)。拆成三段看,问题往往就藏在那几个字节里。


(注:所有代码示例均为说明性伪代码,生产环境请使用成熟的JWT库并处理异常情况。)

Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐