Base64 为什么需要填充
Base64 把 3 个字节转换成 4 个字符(完整算法见 Base64 的工作原理)。当输入长度不是 3 的倍数时,最后一块就是不完整的:
| 最后一块的字节数 | 数据字符数 | 填充 | 示例 |
|---|---|---|---|
| 3 | 4 | 无 | Man → TWFu |
| 2 | 3 | = | Ma → TWE= |
| 1 | 2 | == | M → TQ== |
= 让输出长度保持为 4 的倍数。这让 MIME 时代编写的解码器轻松不少:它们可以按固定的 4 字符一组读取输入,不必记录总长度也能知道数据在哪里结束。
严格来说,这些信息是多余的。最后一组 3 个字符只可能表示 2 个字节,2 个字符只可能表示 1 个字节,所以会数字符的解码器根本不需要 =。这就是不带填充的 Base64 能够存在、而且很多格式都在用的原因。
只有一种情况下填充携带了真正的信息:拼接。如果把两个带填充的字符串拼在一起,中间的 = 标出了第一个字符串在哪里结束。不过解码器对这种情况处理得很糟。把 TQ==TQ==(两个编码后的 M)交给 Python 的默认解码器,返回的是 b'M\x04\xd0';Node 的 Buffer 只返回了 M;atob 则直接抛出异常。正确的做法是分别解码每一段。
什么时候可以省略填充
RFC 4648 §3.2 规定,除非使用 Base64 的规范明确另有说明,否则编码器必须(MUST)添加填充。有几个规范就是这样另行规定的:
- JWT 和 JOSE(RFC 7515)使用去掉填充的 Base64URL。像
eyJhbGciOiJIUzI1NiJ9这样的 JWT 头部,即使长度不是 4 的倍数也没有=。见 JWT 中的 Base64。 - URL 和文件名中常用不带填充的 Base64URL,因为
=在查询字符串中有特殊含义,需要转义成%3D。完整的区别见 Base64 与 Base64URL 页面。 - WebAuthn 和 OAuth PKCE(RFC 7636 中的
code_challenge)同样使用不带填充的 Base64URL。
电子邮件、PEM 文件、data URI 和大多数 JSON API 中的标准 Base64 都带填充。拿不准时,标准 Base64 输出带填充的结果,Base64URL 输出不带填充的结果,并让你的解码器两种都接受。
要求必须有填充的解码器
各种实现的行为差异比大多数人想象的要大。下面是用各库的默认解码器解码 TWE(不带填充的 Ma)的结果:
| 语言 / 工具 | 调用 | TWE 的结果 |
|---|---|---|
| Python 3.13 | base64.b64decode | binascii.Error: Incorrect padding |
| Python 3.13 | base64.urlsafe_b64decode | 同样是 Incorrect padding |
| .NET | Convert.FromBase64String | FormatException |
| Go | base64.StdEncoding | 报错;不带填充的输入请用 RawStdEncoding |
| Go | base64.RawStdEncoding | Ma,但会拒绝 TWE= |
| Ruby | Base64.strict_decode64 | ArgumentError: invalid base64 |
| Ruby | Base64.decode64、urlsafe_decode64 | Ma |
| Java 21 | Base64.getDecoder().decode | Ma |
| Node.js | Buffer.from(s, 'base64')、atob | Ma |
| PHP 8.4 | base64_decode(严格模式也一样) | Ma |
| GNU coreutils 9.7 | base64 -d | Ma |
| OpenSSL 3.5 | openssl base64 -d | 输出为空,退出码为 0 |
注意 OpenSSL 这一行:它什么也没输出,却仍然报告成功,所以用 openssl base64 -d 处理不带填充的 Base64 的脚本,会悄无声息地生成一个空文件。在同一组测试中,它对一行末尾没有换行符的输入也什么都没返回;加上 -A 参数后这种情况就正常了。
Go 在另一个方向上最严格:RawStdEncoding 会拒绝带有填充的字符串。如果你要接受多个来源的输入,可以先去掉 = 再用 raw 编码,或者补上填充再用标准编码。
各语言的示例见代码示例页,例如 Python 中的 Base64 和 Go 中的 Base64。
如何补回缺失的填充
规则只取决于去掉空白之后的字符串长度:
len % 4 | 需要追加 | 含义 |
|---|---|---|
| 0 | 不追加 | 都是完整的块 |
| 2 | == | 最后一块有 1 个字节 |
| 3 | = | 最后一块有 2 个字节 |
| 1 | 无法修复 | 被截断,或混入了多余字符 |
余数为 1 的情况永远无效。一个 Base64 字符只承载 6 比特,不够组成一个字节,所以补多少填充都无法解码。遇到这种情况,请检查是否少了一个字符、多了一个字符,或者把某个换行符也算了进去。
在大多数语言中,修复只需要一个表达式:
import base64
s = "eyJzdWIiOiIxMjM0In0" # 19 chars, 19 % 4 == 3
padded = s + "=" * (-len(s) % 4) # adds one "="
print(base64.urlsafe_b64decode(padded))
# b'{"sub":"1234"}'
const s = "eyJzdWIiOiIxMjM0In0";
const padded = s + "=".repeat((4 - (s.length % 4)) % 4);
// "eyJzdWIiOiIxMjM0In0="
Base64 校验器会报告字符串的长度和填充状态,修复 Base64 则能一步补上缺失的 =、去掉空白和引号,并转换 URL 安全字符。
非规范编码
填充还有一个不太起眼的“亲戚”:最后一个数据字符中未使用的比特。编码 Ma 时,它的 16 个比特填满两个半 6 比特分组。第三个字符承载 a 的最后 4 个比特以及 2 个填充比特,RFC 4648 §3.5 规定编码器必须把这些填充比特置为零,于是得到 E(000100)。
解码器会忽略这些填充比特,所以 TWF=(其中 F 是 000101,填充比特为 01)同样解码为 Ma。在我们的测试中,Python、Node、Java 和 PHP 都接受 TWF= 并返回 Ma。同理,TR== 和 TQ== 一样解码为 M。
像 TWF= 这样的字符串被称为非规范编码。它们在以下几个地方会造成影响:
- 对 Base64 文本计算的签名和哈希。两个不同的字符串会解码成相同的字节,所以通过比较 Base64 字符串来判断数据是否相同,可能得出错误的结论。请比较解码后的字节。
- 去重和缓存键。同样的问题:先解码再重新编码进行规范化。
- 严格的解析器。RFC 4648 允许解码器拒绝非零的填充比特。Go 的
StdEncoding.Strict()和 GNUbase64 -d都会拒绝TWF=。
多余的填充是与之相关的另一个怪现象。TWE== 多了一个 =。Python 和 Node 的 Buffer 接受了它,atob 则抛出了 InvalidCharacterError。
分块编码时的填充
如果分段对一个大文件做 Base64 编码,除最后一段外,每一段都必须是 3 字节的倍数。否则每一段都会带上自己的填充,拼起来的输出就是错的。把 hello world!! 按 4 字节分块编码,得到的是 aGVsbA==byB3bw==cmxkIQ==IQ==;按 3 字节分块编码,得到的是 aGVsbG8gd29ybGQhIQ==,与一次性编码整个内容的结果完全相同。对于 MIME 的换行,每行 57 字节是自然的分块大小,因为 57 个字节恰好变成 76 个字符。
粘贴缺少填充或填充有误的字符串,得到有效的 Base64。
常见问题
可以直接删掉 Base64 末尾的 = 号吗?
数据不会丢失,因为字符串的长度已经告诉解码器最后一块有几个字节。删掉后能否解码取决于接收方:JWT 中的 Base64URL 本来就不带填充,而 Python 的 b64decode 和 .NET 的 Convert.FromBase64String 会拒绝缺少填充的输入。
为什么我的 Base64 字符串末尾没有 =?
要么输入长度正好是 3 字节的倍数,不需要填充;要么生成方使用的是不带填充的 Base64,这在令牌和 URL 中的 Base64URL 里很常见。
Python 报 Incorrect padding 错误怎么解决?
解码前追加 '=' * (-len(s) % 4)。如果字符串是 URL 安全的,还要改用 base64.urlsafe_b64decode。如果长度模 4 余 1,补填充也没用:字符串被截断了,或者混进了多余的字符。
Base64 字符串能以三个 = 结尾吗?
不能。最后一块只会剩下 1 或 2 个字节,分别产生 == 或 =。三个 = 意味着一个没有数据的块,而这样的块并不存在。