上传文件
  1. base64hub
  2. 学习 Base64
  3. Base64 与 Base64URL
指南

Base64 与 Base64URL:字母表、填充与常见坑

Base64URL 就是改了两个字符的 Base64,这样输出可以原样放进 URL 或文件名。字节完全一样,字母表只有两处不同。

一张表看懂区别

两种变体都定义在 RFC 4648 中:第 4 节是标准 Base64,第 5 节是“URL 和文件名安全”的字母表。它们共用 62 个字符和同一套算法,只有索引 62 和 63 不同。

标准 Base64(RFC 4648 §4)Base64URL(RFC 4648 §5)
索引 0–61A–Z a–z 0–9相同
索引 62+-
索引 63/_
填充默认必须有 =通常省略
可直接放进 URL 路径或查询参数否是
可用作文件名否(/ 是路径分隔符)是

同一个输入分别用两种方式编码:

输入标准Base64URLBase64URL,不填充
<<???>>PDw/Pz8+Pg==PDw_Pz8-Pg==PDw_Pz8-Pg
字节 fb ff+/8=-_8=-_8
ManTWFuTWFuTWFu

最后一行提醒我们:很多字符串在两种变体下完全一样。只有包含 +、/、- 或 _,或者缺少填充时,才能区分出来。分辨不出时,用一个两种字母表都接受的解码器最稳妥。

拿到一个字符串时,可以这样快速判断:

  • 含有 - 或 _:Base64URL。
  • 含有 + 或 /:标准 Base64。
  • 长度不是 4 的倍数且没有 =:没有填充,几乎总是 Base64URL。
  • 同时含有 + 和 -,或同时含有 / 和 _:有什么东西把两种字母表混在了一起,或者这根本不是 Base64。Base64 校验器会标出哪些字符不对。
  • 由点号分成三段,并以 eyJ 开头:这是 JWT,按定义就是 Base64URL。

为什么会有 Base64URL

在 URL 中,/ 分隔路径段,+ 在表单编码的查询字符串里表示空格,= 分隔键和值。标准 Base64 这三个字符全都会用到。把它放进 URL 就得做百分号编码,ab+/8= 会变成 ab%2B%2F8%3D,更长、更难看,还多了一个容易出错的步骤。

RFC 4648 §5 选用了 - 和 _ 来解决这个问题:它们在 URL 中属于非保留字符,在所有常见文件系统中都可以出现在文件名里。RFC 还指出,长度已知时可以省略填充,因为 = 本身也需要转义成 %3D。详见 填充指南。

两种变体分别用在哪里

标准 Base64:

  • MIME 电子邮件附件和编码后的邮件头(电子邮件中的 Base64)
  • PEM 证书和密钥
  • HTML 和 CSS 中的 data: URI
  • HTTP Basic 认证请求头
  • Kubernetes Secret 以及大多数传输文件的 JSON API

Base64URL,通常不带填充:

  • JSON Web Token:头部、载荷和签名都是不带填充的 Base64URL(JWT 中的 Base64)
  • JWK 密钥,以及整个 JWS 和 JWE(RFC 7515–7518)
  • WebAuthn 的凭据 ID 和挑战值
  • OAuth PKCE 的 code_challenge 值(RFC 7636)
  • 随机 URL 令牌,例如 Python 的 secrets.token_urlsafe(),32 个随机字节会得到 43 个字符的字符串

两者之间的转换

转换只是字符替换,填充单独处理,字节本身不会变。

标准转 URL 安全:把 + 换成 -,/ 换成 _,再去掉末尾的 =。 URL 安全转标准:把 - 换成 +,_ 换成 /,然后补 = 直到长度是 4 的倍数。

const toUrl = (b64) =>
  b64.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");

const fromUrl = (u) => {
  const b64 = u.replace(/-/g, "+").replace(/_/g, "/");
  return b64 + "=".repeat((4 - (b64.length % 4)) % 4);
};

toUrl("PDw/Pz8+Pg==");   // "PDw_Pz8-Pg"
fromUrl("PDw_Pz8-Pg");   // "PDw/Pz8+Pg=="

大多数语言也自带直接的编码器,很少需要自己倒腾字符串:

语言Base64URL 编码器填充
Pythonbase64.urlsafe_b64encode(b)保留;用 .rstrip(b"=") 去掉
Node.jsbuf.toString("base64url")省略
Gobase64.RawURLEncoding / base64.URLEncoding省略 / 保留
JavaBase64.getUrlEncoder().withoutPadding()省略
PHPrtrim(strtr(base64_encode($s), '+/', '-_'), '=')省略

解码时,Node 的 Buffer.from(s, "base64") 两种字母表都接受,也不在乎缺少填充。Python 的 base64.urlsafe_b64decode 需要先补回填充。浏览器的 atob() 遇到 - 和 _ 会抛出 InvalidCharacterError,所以要先转换,或者在支持的环境中使用带 alphabet: "base64url" 的 Uint8Array.fromBase64。其他语言的代码见代码示例页,例如 Python 和 Java。

如果只是要转换一个字符串,Base64 转 Base64URL 和 Base64URL 转 Base64 可以直接在浏览器里完成。

常见坑

+ 变成了空格

这是最常见的 bug。标准 Base64 不经转义直接放进查询字符串,会被表单解码搞乱,因为它把 + 当作空格:

from urllib.parse import parse_qs
parse_qs("t=ab+/8=")
# {'t': ['ab /8=']}
new URLSearchParams("t=ab+/8=").get("t");
// "ab /8="

下游的解码器要么在空格处报错,要么跳过空格返回错误的字节。解决办法是改用 Base64URL,或者在传入时调用 encodeURIComponent()。作为接收方的临时补救,把空格替换回 + 通常可行,因为 Base64 中从来不会出现空格。

悄悄混用字母表

除非传入 validate=True,Python 的 base64.b64decode 会丢弃标准字母表以外的字符。给它一个 Base64URL 字符串,它会扔掉 - 和 _,然后解码剩下的部分:

import base64
base64.b64decode("PDw_Pz8-Pg==")
# b'<<\x0f\xcf\xc3\xe0'   (wrong; the input was "<<???>>")
base64.b64decode("PDw_Pz8-Pg==", validate=True)
# binascii.Error: Only base64 data is allowed
base64.urlsafe_b64decode("PDw_Pz8-Pg==")
# b'<<???>>'

第一次调用不会报错,却返回了错误的字节。URL 安全的输入请用 urlsafe_b64decode,或者加上 validate=True,让不匹配的问题暴露出来。

双重编码

这个名字指的是两种不同的情况,实际中都会遇到。

百分号编码了两次。标准 Base64 字符串被你的代码转义一次,又被 HTTP 客户端转义一次,于是 + 先变成 %2B,再变成 %252B。服务器只解码一次,收到的是字面上的 %2B。Base64URL 没有这个问题,因为根本没有需要转义的字符。

Base64 套 Base64。一个值在两层被编码,例如应用编码一次,框架或 Kubernetes 清单工具又编码一次。Man 先变成 TWFu,再变成 VFdGdQ==。识别方法是:解码后得到的仍然全是 Base64 字符。再解码一次,看结果是否说得通。

对填充的预期不一致

有的接收方要求必须有填充,有的则拒绝填充。Go 的 RawURLEncoding 遇到含 = 的输入会失败,而 Python 的解码器遇到缺少填充的输入会失败。设计 API 时,请在文档中写明变体和填充规则,并让你自己的解码器两种都接受。

解码 Base64URL

粘贴 URL 安全的字符串,有没有填充都可以,看看里面是什么。

Base64URL 解码器

常见问题

Base64URL 等于对 Base64 字符串做 URL 编码吗?

不是。百分号编码会把 + 变成 %2B、/ 变成 %2F,字符串会变长。Base64URL 换用了另一套字母表,根本不需要转义。

标准 Base64 解码器能解 Base64URL 吗?

不一定。有些解码器(比如 Node 的 Buffer)两种字母表都接受;有些会拒绝 - 和 _;而 Python 的 b64decode 默认会悄悄丢弃它们,得到错误的字节。

Base64URL 有填充吗?

RFC 4648 允许有填充,Python 的 urlsafe_b64encode 也会保留。但包括 JWT 在内,大多数使用 Base64URL 的格式都会去掉 =,因为长度本身已经能确定填充。

URL 里的令牌该用哪一种?

用不带填充的 Base64URL。它放在路径、查询字符串、Cookie 或文件名中都不需要转义。

继续阅读