上传文件
  1. base64hub
  2. 学习 Base64
  3. 填充(=)
详解

Base64 填充:末尾的 = 号有什么用

Base64 末尾的 = 标志着最后一块不完整。有的格式会去掉它,有的解码器又非要它不可,而补回它只需一行代码。

Base64 为什么需要填充

Base64 把 3 个字节转换成 4 个字符(完整算法见 Base64 的工作原理)。当输入长度不是 3 的倍数时,最后一块就是不完整的:

最后一块的字节数数据字符数填充示例
34无Man → TWFu
23=Ma → TWE=
12==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.13base64.b64decodebinascii.Error: Incorrect padding
Python 3.13base64.urlsafe_b64decode同样是 Incorrect padding
.NETConvert.FromBase64StringFormatException
Gobase64.StdEncoding报错;不带填充的输入请用 RawStdEncoding
Gobase64.RawStdEncodingMa,但会拒绝 TWE=
RubyBase64.strict_decode64ArgumentError: invalid base64
RubyBase64.decode64、urlsafe_decode64Ma
Java 21Base64.getDecoder().decodeMa
Node.jsBuffer.from(s, 'base64')、atobMa
PHP 8.4base64_decode(严格模式也一样)Ma
GNU coreutils 9.7base64 -dMa
OpenSSL 3.5openssl 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() 和 GNU base64 -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 修复

常见问题

可以直接删掉 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 个字节,分别产生 == 或 =。三个 = 意味着一个没有数据的块,而这样的块并不存在。

继续阅读