Base64 编码详解:原理、用途与常见坑

9 min2026年5月11日

Base64 编码到底在干什么

Base64 编码一句话解释:把任意二进制数据转换成由 64 个可打印 ASCII 字符组成的字符串。没有加密,没有压缩,没有黑魔法——只是一种可逆的字节到文本的转换。名字来源于所使用的 64 字符字母表:A-Z、a-z、0-9,加上两个额外字符(标准变体中是 + 和 /)。

这个算法在 RFC 4648(2006 年发布,取代更早的 RFC 3548)中被正式定义。它存在的原因是:很多系统——邮件协议、JSON 载荷、XML 文档、URL 参数——设计之初只能承载文本,不能直接传输原始字节。当你需要把一张 PNG 图片塞进邮件,或者在 JSON API 响应里嵌入一个 PDF 时,Base64 提供了一种把二进制数据表示为纯文本的方法。代价是体积:每 3 字节输入变成 4 字符输出。

大多数教程忽略的一点:Base64 和 UTF-8、ASCII 不是同一类"编码"。UTF-8 和 ASCII 是把字符映射到字节,Base64 是把字节映射到字符——方向正好相反。UTF-8 让计算机存储文本,Base64 让文本系统承载二进制。

Base64 编码算法(逐步拆解)

算法以 3 字节(24 位)为一组处理输入。把 24 位拆成四个 6 位组,每个 6 位值(0-63)对应 Base64 字母表中的一个字符。3 字节输入变成 4 字符输出——这就是 Base64 体积固定增长 33% 的原因(还要加上填充)。

当输入长度不能被 3 整除时,填充机制启动。剩余 1 字节会补零后产生 2 个 Base64 字符加 "=="。剩余 2 字节产生 3 个 Base64 字符加 "="。填充字符告诉解码器末尾需要丢弃多少字节。有些实现(比如 base64url)直接省略填充,因为长度本身就能推断出来。

来跟踪一下编码字符串 "Hi"(两个字节:0x48 0x69)的过程。二进制是 01001000 01101001。需要凑够 24 位,补零:01001000 01101001 00000000。拆成 6 位组:010010 000110 100100 000000。映射到索引:18、6、36、0。查字母表:S、G、k、A。因为补了一个字节的零,加一个 "="。结果:"SGk="。

2014 年我第一次手写实现时就搞错了——忘记补的零是最后一个 6 位组的一部分,不是独立的。解码器需要知道最后那个 "A" 代表的是补零,不是真正的零字节。"=" 就是这个信号。

// Encoding "Hi" step by step
const input = "Hi";
const bytes = [0x48, 0x69]; // H=72, i=105

// Step 1: Convert to binary (pad to 24 bits)
// 01001000 01101001 00000000
//
// Step 2: Split into 6-bit groups
// 010010 | 000110 | 100100 | 000000
//   18       6       36       0
//
// Step 3: Map to Base64 alphabet
//   S        G       k        A
//
// Step 4: Add padding (1 byte was padded)
// Result: "SGk="

console.log(btoa("Hi")); // "SGk="
console.log(atob("SGk=")); // "Hi"

// In Node.js:
Buffer.from("Hi").toString("base64"); // "SGk="
Buffer.from("SGk=", "base64").toString(); // "Hi"

33% 的体积膨胀(为什么这很重要)

每 3 字节输入产生 4 字节输出,固定增长 33.3%,还没算填充和换行。一张 1 MB 的图片,Base64 版本至少 1.33 MB。一个 10 MB 的视频缩略图嵌入 JSON 响应,你实际传输的是 13.3 MB。这个开销累积起来很可观。

我曾经排查过一个移动端 App 疯狂消耗用户流量的问题。API 把用户头像作为 Base64 字符串塞在 JSON 里返回。每个 200 KB 的头像变成 267 KB 的 Base64 文本,一个包含 20 个头像的 JSON 响应就是 5.3 MB。改成图片 URL 配合 CDN 后,载荷降到了 4 KB。教训:能嵌入不代表应该嵌入。

还有一个容易被忽略的代价:Base64 字符串不能流式处理或部分解码。二进制文件可以边下载边渲染前面的字节,Base64 通常需要拿到完整字符串才能解码。在慢速网络上处理大载荷时,这个差异很明显。

什么时候该用 Base64(真实使用场景)

邮件附件(MIME):这是 Base64 最初的用途。SMTP 协议设计时只支持 7 位 ASCII 文本。二进制附件经过 Base64 编码后才能安全通过可能会截断第 8 位的邮件服务器。你发过的每一个邮件附件,底层都在用 Base64。

HTML/CSS 中的 Data URI:把小图片直接嵌入标记可以省掉一次 HTTP 请求。一个 2 KB 的图标做成 data URI(data:image/png;base64,...)能省一次往返。但超过 5 KB 的资源通常还是单独文件更好——Base64 的体积开销加上无法独立缓存,整体算下来是亏的。

JSON 和 XML 载荷:当 API 需要在文本格式中包含二进制数据(签名图片、小文件、加密密钥)时,Base64 是标准做法。AWS S3 预签名 POST 策略用 Base64 编码的 JSON。JWT 的 header 和 payload 用 Base64url 编码。

HTTP Basic 认证:Authorization 头把凭据以 Base64 编码的 "username:password" 形式发送。这不是加密——任何截获这个头的人都能瞬间解码。只是为了确保密码中的特殊字符不会破坏 HTTP 头格式。Basic Auth 必须配合 HTTPS 使用。

什么时候不该用 Base64(常见误区)

别用 Base64 做安全防护。我见过生产代码把 API 密钥 Base64 编码一下就当"加密"了。这提供零安全性。对 Base64 字符串跑一下 atob() 只需要微秒级时间。如果需要保护数据,用真正的加密(AES-256-GCM)或哈希(SHA-256)。Base64 是编码,不是加密。

别在 JSON 响应里嵌入大文件。如果你的 API 返回图片、PDF 或视频,把它们作为独立的二进制响应配合正确的 Content-Type 头来提供。用指向 CDN 或对象存储的 URL。33% 的体积开销、无法独立缓存、大字符串带来的内存压力——这些都说明超过几 KB 的内容不适合内联 Base64。

别在 URL 中使用标准 Base64 而不切换到 base64url。标准 Base64 使用 + 和 /,这两个字符在 URL 中有特殊含义。base64url 变体(RFC 4648 §5)用 - 和 _ 替换它们,并去掉 = 填充。JWT 就是因为这个原因使用 base64url。如果你在查询参数中使用标准 Base64 而不先做 URL 编码,数据会被损坏。

别对已经是文本的数据做 Base64 编码。我审查过把 JSON Base64 编码后再塞进 JSON 字段的代码。结果是双重编码的数据,体积大了 33%,还更难调试。如果数据本身就是合法文本(UTF-8 字符串、JSON、XML),直接放进去就行。

Base64 变体:标准、URL 安全和 MIME

RFC 4648 定义了几种 Base64 字母表。标准字母表(Table 1)使用 A-Z、a-z、0-9、+、/,用 = 做填充。这是 btoa() 和大多数库默认产生的格式。

URL 安全字母表(Table 2,通常叫 "base64url")用 - 替换 +,用 _ 替换 /,以避免与 URL 语法冲突。填充可选省略。你会在 JWT、OAuth token 以及任何 Base64 数据出现在 URL 或文件名中的场景看到它。Node.js 中用 Buffer.from(data).toString("base64url")。

MIME Base64(用于邮件)是标准字母表加上每 76 字符一个换行。有些老解码器遇到没有换行的 Base64 会报错,有些现代解码器遇到有换行的 Base64 会报错。永远搞清楚你的消费方期望哪种变体。

还有 Base32(RFC 4648 §6),只使用大写字母和数字 2-7。体积比输入大 60%(Base64 是 33%),但不区分大小写,避免了 0/O 和 1/l 这类容易混淆的字符。TOTP 验证码(Google Authenticator)用 Base32 存储共享密钥,因为用户需要手动输入。如果你在设计一个需要用户手动输入编码数据的系统,Base32 值得考虑,尽管体积代价更大。

各语言 Base64 操作速查

JavaScript(浏览器):btoa() 把字符串编码为 Base64,atob() 解码。坑:btoa() 只能处理 Latin-1 字符。对于 UTF-8 字符串,需要 btoa(unescape(encodeURIComponent(str))) 或更新的 TextEncoder 方案。2024 年以后,大多数环境支持 TextEncoder/TextDecoder 的 base64 编码选项。

Node.js:Buffer.from(data).toString("base64") 编码,Buffer.from(b64, "base64") 解码。base64url 变体传 "base64url" 即可。这能正确处理二进制数据,没有 btoa() 的 Latin-1 限制。

Python:import base64; base64.b64encode(bytes_data) 和 base64.b64decode(b64_string)。URL 安全版本:base64.urlsafe_b64encode()。注意这些函数操作的是 bytes 对象而非字符串——需要 .encode("utf-8") 和 .decode("utf-8") 转换。

Go:encoding/base64 包,base64.StdEncoding.EncodeToString() 和 base64.URLEncoding 用于 URL 安全变体。Go 的实现速度很快——处理大输入时比 Python 快约 3 倍,得益于编译型语言的特性。

// Browser: handling UTF-8 properly
const text = "Hello 你好 🌍";

// ❌ btoa() fails on non-Latin-1 characters
// btoa(text) → throws InvalidCharacterError

// ✅ Correct approach for UTF-8
const encoded = btoa(
  String.fromCharCode(...new TextEncoder().encode(text))
);
// "SGVsbG8g5L2g5aW9IPCfjI0="

const decoded = new TextDecoder().decode(
  Uint8Array.from(atob(encoded), c => c.charCodeAt(0))
);
// "Hello 你好 🌍"

// Node.js: much simpler
Buffer.from(text).toString("base64");
Buffer.from(encoded, "base64").toString("utf-8");

Base64 问题排查指南

解码后输出乱码:大概率是字符编码不匹配。最常见的情况:数据以 UTF-8 字节编码,但你把 Base64 输出当 Latin-1 解码了(或者反过来)。永远在 Base64 编码前先转 UTF-8,在 Base64 解码后按 UTF-8 解读。2019 年我在这个 bug 上花了 4 个小时——一个移动端 App 在 Base64 字段里发送 emoji,服务端按 ISO-8859-1 解码,🎉 变成了乱码。

"Invalid character" 错误:检查空白字符。MIME 格式的 Base64 每 76 字符有换行(\r\n),严格的解码器会拒绝。解码前先去掉所有空白。另外检查是不是用错了变体——包含 - 和 _ 的 base64url 字符串在标准 Base64 解码器里会报错。

填充错误:有些解码器要求填充(=),有些拒绝填充。如果遇到 "incorrect padding" 错误,试着加 = 直到字符串长度能被 4 整除。如果 = 本身报 "invalid character",试着去掉所有 =。最安全的做法:解码前统一规范化为带填充的标准 Base64。

双重编码:如果解码后的输出看起来还是 Base64(全是字母数字加 + / =),说明有人编码了两次。再解码一次。我在生产环境见过三重编码的数据——每层中间件都"为了安全"Base64 编码了一次载荷。用我们的 Base64 工具反复解码,直到得到可读输出。