令牌的结构
下面是一个完整的 HS256 令牌,使用演示密钥 demo-secret-do-not-use 签名。为便于阅读加了换行,真实的令牌只有一行:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9
.eyJzdWIiOiJ1c2VyLTQyIiwibmFtZSI6IkFkYSIsImFkbWluIjpmYWxzZSwiaWF0IjoxNzYwMDAwMDAwLCJleHAiOjE3NjAwMDM2MDB9
.nKTVXOM_r1JbE92WsFd2SSun0a1TByUV8ZpqrRUnIhk
用 . 分隔的三个部分:
| 部分 | Base64URL 长度 | 解码结果 |
|---|---|---|
| 头部 | 36 | {"alg":"HS256","typ":"JWT"} |
| 载荷 | 104 | {"sub":"user-42","name":"Ada","admin":false,"iat":1760000000,"exp":1760003600} |
| 签名 | 43 | 32 个 HMAC-SHA256 原始字节 |
头部和载荷是先以 UTF-8 编码、再以 Base64URL 编码的 JSON。签名是二进制数据,所以只有作为字节才有意义。iat 和 exp 是以秒为单位的 Unix 时间戳:这个令牌签发于 2025-10-09 08:53:20 UTC,一小时后过期。
为什么用不带填充的 Base64URL
令牌会经过一些 +、/ 和 = 会带来麻烦的地方:查询字符串(+ 会变成空格)、URL 路径(/ 是分隔符)、Cookie 和 HTTP 头。因此,JWT 背后的签名格式 JWS(定义于 RFC 7515)使用 RFC 4648 中的 URL 安全字母表,并去掉填充:
使用 RFC 4648 第 5 节定义的 URL 与文件名安全字符集进行 Base64 编码,省略所有末尾的 ‘=’ 字符(第 3.2 节允许这样做),并且不包含任何换行符、空白或其他附加字符。
由此带来与标准 Base64 的两点不同:
+变成-,/变成_。上面的签名中就有_,标准解码器会拒绝它。- 末尾没有
=。一个段的长度永远不会是 4n+1,所以解码器总能算出去掉了多少填充:长度除以 4 余 2 表示==,余 3 表示=。
字母表差异的更多内容见 Base64 与 Base64URL 的区别。
手动解码各个部分
按点号拆分,恢复标准字母表和填充,解码,然后解析 JSON。在浏览器中:
function decodePart(part) {
const b64 = part.replace(/-/g, "+").replace(/_/g, "/");
const padded = b64 + "=".repeat((4 - (b64.length % 4)) % 4);
const bytes = Uint8Array.from(atob(padded), (c) => c.charCodeAt(0));
return JSON.parse(new TextDecoder().decode(bytes));
}
const [header, payload] = token.split(".").slice(0, 2).map(decodePart);
跳过 TextDecoder 这一步,非 ASCII 文本就会出错。载荷 {"name":"Zoë 日本"} 编码后是 eyJuYW1lIjoiWm_DqyDml6XmnKwifQ。如果只用 atob 处理转换后的字符串,名字会变成以 Zoë 开头的乱码,因为 atob 每个字节返回一个字符。在支持它的运行环境中,Uint8Array.fromBase64(part, { alphabet: "base64url" }) 可以替代前三行,见 Uint8Array.toBase64。在 Node.js 中,Buffer.from(part, "base64url") 也能做到同样的事。
在 Python 中,urlsafe_b64decode 能处理字母表,但仍然需要填充:
import base64, json
def b64url_decode(s: str) -> bytes:
return base64.urlsafe_b64decode(s + "=" * (-len(s) % 4))
header, payload, signature = token.split(".")
print(json.loads(b64url_decode(header))) # {'alg': 'HS256', 'typ': 'JWT'}
print(json.loads(b64url_decode(payload))) # {'sub': 'user-42', 'name': 'Ada', ...}
在 shell 中,用 tr 转换字母表,再用循环补填充:
jwt_part() {
local s
s=$(printf '%s' "$1" | tr '_-' '/+')
while [ $(( ${#s} % 4 )) -ne 0 ]; do s="$s="; done
printf '%s' "$s" | base64 -d
}
jwt_part "$(echo "$TOKEN" | cut -d. -f2)"
如果只是想看看令牌里有什么,把它粘贴到 JWT 解码器 中即可。它在你的浏览器中运行,所以生产环境的令牌不会离开你的电脑。
为什么载荷是可读的
编码不是加密。Base64URL 只改变字节的书写方式,JWT 解码器 不需要任何密钥就能还原它。签名 JWT 保证载荷自签名后没有被改动过,但它并不隐藏载荷。
编码还会占用空间。每个声明经过 Base64URL 编码后都会增大三分之一,而整个令牌会随每个请求放在请求头或 Cookie 中传输,所以要让载荷保持精简:一个 ID 加几个简短的声明,而不是一份完整的用户资料。
把每个声明都当作公开信息。用户 ID、角色和过期时间都没问题。密码、API 密钥以及不应让令牌持有者看到的个人数据则不行。如果内容必须保密,请使用加密的 JWT(JWE)。它的紧凑格式由五个 Base64URL 部分组成:头部、加密的密钥、IV、密文和认证标签。编码与加密的区别 一文从总体上讲解了两者的差异。
签名是如何计算的
签名覆盖的是确切的 ASCII 文本 header.payload,即中间用点号连接的两个 Base64URL 字符串。对于 HS256:
import hmac, hashlib
signing_input = f"{header}.{payload}".encode("ascii")
mac = hmac.new(b"demo-secret-do-not-use", signing_input, hashlib.sha256).digest()
hmac.compare_digest(mac, b64url_decode(signature)) # True
原始签名字节随后会被编码为不带填充的 Base64URL。它的长度取决于算法:
| 算法 | 签名字节数 | Base64URL 字符数 |
|---|---|---|
| HS256 | 32 | 43 |
| ES256 | 64 | 86 |
| RS256(2048 位密钥) | 256 | 342 |
JWS 中的 ES256 签名是原始的 R || S 值,各 32 个字节。OpenSSL 和许多加密库生成的则是 DER 编码的 ECDSA 签名,它以 30 开头,长度可变。两者之间的转换是 “invalid signature” 错误的常见来源。
要用 HS、RS、ES 或 EdDSA 密钥生成并签名令牌,请使用 JWT 编码器。要生成 Base64 形式的原始 HMAC,见 HMAC 工具。
常见错误
- 直接使用
atob或标准 Base64 解码器:对有些令牌能用,对另一些则会报错,取决于里面是否碰巧出现了-或_。我们的示例中,载荷可以用atob解码,签名则不行。 - 生成令牌时加上填充:有些库会拒绝以
=结尾的段。请去掉它。 - 验证之前重新编码头部或载荷:签名针对的是原始的 Base64URL 文本。解析 JSON 再重新序列化,可能会改变键的顺序或空白,签名就对不上了。请始终针对收到的原始段进行验证。
- 信任解码出的载荷:解码不等于验证。请用预期的算法检查签名,并且除非你确实有意为之,否则绝不要接受
"alg":"none"(头部为eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0)。 - 把令牌字符串当作唯一键:32 个签名字节需要 256 位,而 43 个字符承载 258 位。最后 2 位没有用到,所以在我们的示例中,最后的
k可以换成l、m或n,宽松的解码器仍会返回相同的字节,签名也仍能验证通过。这对安全无害,但两个不同的字符串可能代表同一个令牌。 - 把时间戳当作毫秒:
exp和iat的单位是秒。传给 JavaScript 的Date之前要先乘以 1000。
常见问题
JWT 的载荷是加密的吗?
不是。普通的签名 JWT(JWS)只是对载荷做了 Base64URL 编码,所以任何拿到令牌的人都能读取它。加密令牌使用 JWE,它由五部分而不是三部分组成。
为什么 JWT 末尾没有 = 号?
RFC 7515 把编码定义为去掉所有末尾 = 的 Base64URL,这样令牌放进 URL 和请求头时无需转义。解码器必须把填充补回去,或者接受不带填充的输入。
不知道密钥也能解码这三部分吗?
能。头部和载荷都是普通的 Base64URL。只有验证签名或生成新签名时才需要密钥。JWT 解码器 会显示这两部分,如果你提供密钥,它还会校验签名。
为什么每个 JWT 都以 eyJ 开头?
头部是一个以 {" 开头的 JSON 对象,而这两个字节编码后总是 eyJ。更多类似的前缀见 常见的 Base64 前缀。