上传文件
  1. base64hub
  2. 学习 Base64
  3. JWT 中的 Base64
详解

JWT 如何使用 Base64URL:手动解码令牌

JWT 就是用点号连接起来的三个 Base64URL 字符串。前两部分任何人都能解码,保护它们的只有签名。下面介绍每一部分是如何生成和读取的。

令牌的结构

下面是一个完整的 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}
签名4332 个 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 字符数
HS2563243
ES2566486
RS256(2048 位密钥)256342

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 前缀。

继续阅读