发布于 更新于
Base64 把任意字节重新表达成一套 64 个字符的字母表,好让它们穿过那些只为文本设计的通道。它是工程师每周都会撞见的少数几种编码之一 —— JWT、Data URL、HTTP Basic 凭据、邮件附件、TLS 证书里都有它 —— 也是被误读得最频繁的一种。它是一种传输格式,不是密码,不是压缩,也不是字符编码。
Base64 真正解决的问题
一套系统里有相当多的通道,承诺只能携带文本。HTTP 头部的值、JSON 字符串、XML 属性、SMTP 正文、CSS 里的 url() 引用、Shell 环境变量,每一种都有结构性字符、有被明令禁止的字符,而在更早的协议里还继承了一个假设:每个字节只有低 7 位是有意义的。把原始二进制直接塞进去,路径上总会有某个环节重新解释某个字节、吞掉某个控制符,或者擅自改写换行。
Base64 绕开了全部这些问题:它把输入每 3 个字节映射成 4 个字符,字符取自一套所有 ASCII 兼容系统都认可的集合 —— A 到 Z、a 到 z、0 到 9,再加两个符号。结果比原文长,但它能原封不动地穿过只认文本的管道,解码后还原成一模一样的字节,不存在哪个字节被认错的余地。
把这个定位说清楚是有必要的,因为它划出了工具的边界。当一段字节必须穿过一个只承诺承载文本的环节时,才用 Base64。不要因为编码之后「看不懂了」就用它 —— 不可读只是字母表带来的副作用,不是一项可以依赖的性质。
变换本身是怎么做的
Base64 完全无视字节边界,它把输入当成一串比特。每次取 24 位 —— 也就是 3 个字节 —— 重新切成 4 组,每组 6 位。6 位能表示 0 到 63,正好直接作为字母表的下标。整个算法就这么多,剩下的全是在处理「输入长度不是 3 的倍数」这一种情况。
亲手算一遍例子,是最快摆脱猜测的办法。取 3 字节的字符串 Man,它的字节是 0x4D、0x61、0x6E。把比特拼起来,按 6 位切开,再逐个查表。
因为字母表有 64 项、每个输出字符恰好携带 6 位,膨胀系数是固定的:每 3 字节出 4 个字符,也就是字节数除以 3 向上取整再乘 4。不存在任何一种输入能让 Base64 变短,也不存在哪种内容类型能让它「压缩」。对已经压缩过的文件再做 Base64 只会更大 —— 所以在流水线里 gzip 永远排在 Base64 前面,不能反过来。
输入字节 M = 0x4D a = 0x61 n = 0x6E
比特流 0 1 0 0 1 1 0 1 0 1 1 0 0 0 0 1 0 1 1 0 1 1 1 0
重新分组 0 1 0 0 1 1 | 0 1 0 1 1 0 | 0 0 0 1 0 1 | 1 0 1 1 1 0
取值 19 22 5 46
查表 T W F u
结果 Man -> TWFu
字母表下标: 0-25 = A-Z 26-51 = a-z 52-61 = 0-9 62 = + 63 = /标准 Base64 与 Base64URL
下标 62 和 63 那两个符号,是常见变体之间唯一的分歧所在,而分歧的原因是加号和斜杠在 URL 里都另有含义。斜杠分隔路径段;加号会被任何按 application/x-www-form-urlencoded 解析的程序还原成空格。于是一个标准 Base64 值被塞进查询串之后,可能安然无恙,也可能被改坏,完全取决于它碰巧包含了哪些字节。
Base64URL 与标准 Base64 一同定义在 RFC 4648 中,它把这两个字符换成连字符和下划线 —— 两者在 URL 里都属于非保留字符,无需再做转义。按惯例它还会省掉末尾的补位符,因为等号在查询串里同样有自己的含义。JWT、WebAuthn 载荷以及多数现代令牌格式用的都是这个变体。
两种变体不能互换,而且失败得很安静。用严格的标准解码器去解 Base64URL 值,会在第一个连字符处抛出非法字符错误;反过来用严格的 URL 安全解码器解标准值,则会在第一个加号处报错。很多库比较宽容、两种都收,这份便利会一直好用,直到链路上那个唯一严格的服务拒绝了一个在别处都能用的令牌。
两个变体编码的字节完全相同,区别只在字母表最后两项和补位约定。
| 属性 | 标准(RFC 4648 §4) | URL 安全(RFC 4648 §5) |
|---|---|---|
| 下标 62 | + | - |
| 下标 63 | / | _ |
| 补位 | 规范要求带 = | 通常省略 = |
| 可直接放进 URL 路径或查询 | 否 | 是 |
| 字节 0xFB 0xFF 编码为 | +/8= | -_8 |
| 常见场景 | MIME、PEM、Data URL、Basic 认证 | JWT、WebAuthn、OAuth PKCE、URL 短标识 |
补位,以及解码器为什么在这件事上吵架
当输入长度不是 3 的倍数时,最后一组只有 8 位或 16 位,而不是 24 位。编码器补零到下一个 6 位边界,输出那 2 个或 3 个真正携带数据的字符,再追加等号,使输出长度始终是 4 的倍数。余 1 个字节产生 2 个字符加 2 个等号;余 2 个字节产生 3 个字符加 1 个等号。
补位本身不携带任何信息。输出长度已经足以让解码器推断出最后一组有几个字节,这正是 Base64URL 敢于省掉它的原因。补位换来的是「把多个编码值拼接起来仍能找到边界」的能力,这件事对 MIME 和 PEM 很重要,对 URL 里的一个令牌则毫无意义。
实际的争执几乎都来自严格程度不一致,而不是规则本身。Python 的 base64.b64decode 在缺补位时会抛 binascii 错误,除非你先补回去;现代引擎里 JavaScript 的 atob 接受不带补位的输入;Java 的 Base64.getUrlDecoder 两种都收。可移植的习惯是:按接收方文档写明的方式编码;解码来路不明的值时,先补齐到 4 的倍数,再交给严格的库。
"Man" (3 字节,整除) -> TWFu 不补位
"Ma" (2 字节,差 1) -> TWE= 一个 "="
"M" (1 字节,差 2) -> TQ== 两个 "="
同样输入的不带补位 Base64URL:TWFu / TWE / TQ
交给严格解码器之前先补齐:
const padded = value + "=".repeat((4 - (value.length % 4)) % 4);文本、UTF-8,以及最容易被跳过的那一步
Base64 编码的是字节。字符串在你选定字符编码之前还不是字节,而这个选择发生在 Base64 运行之前。几乎所有涉及非 ASCII 文本的「Base64 问题」,本质上都是字符编码问题 —— Base64 忠实地把它保留了下来,然后替真凶背了锅。
选 UTF-8,往返就是干净的。汉字「中」的 UTF-8 表示是 3 个字节 0xE4 0xB8 0xAD,编码结果是 5Lit;把 5Lit 解码后按 UTF-8 解释,拿回来的就是「中」。如果一端用 GBK、另一端用 UTF-8,那些字节照样能完美穿过 Base64 —— 它们只是被解释成了别的字符。乱码出现在链路末端,看上去却像是编码那一步的错。
对中文开发者来说,最常踩的坑是浏览器里的 atob 和 btoa。这两个函数操作的是 Latin-1 码元,因此只要字符串里出现码点高于 U+00FF 的字符,btoa 立刻抛出 InvalidCharacterError —— 任意一个汉字都会触发。解决办法不是找一个更聪明的 Base64 实现,而是先用 TextEncoder 把字符串序列化成 UTF-8 字节,再对这些字节做 Base64。服务端语言大多在 API 上就要求显式指定编码,这也是为什么这个故障在前端代码里格外常见。
btoa("中"); // InvalidCharacterError
const bytes = new TextEncoder().encode("中"); // Uint8Array [228, 184, 173]
btoa(String.fromCharCode(...bytes)); // "5Lit"
new TextDecoder().decode(
Uint8Array.from(atob("5Lit"), (c) => c.charCodeAt(0))
); // "中"
对照值,先 UTF-8 再 Base64:
"hello" 68 65 6C 6C 6F -> aGVsbG8=
"中" E4 B8 AD -> 5Lit
"中文" E4 B8 AD E6 96 87 -> 5Lit5paH
"你好" E4 BD A0 E5 A5 BD -> 5L2g5aW9
"汉字" E6 B1 89 E5 AD 97 -> 5rGJ5a2XData URL、图片,以及内联的代价
Data URL 把媒体类型和载荷打包进一个 URI,于是一张图片可以直接住在 HTML 属性或 CSS 规则里,不需要第二次请求。它的形状是 data:、MIME 类型、字面量 ;base64,然后是编码后的字节。PNG 永远以同一个 8 字节签名开头,所以网上每一个 Base64 编码的 PNG 都以 iVBORw0KGgo 开头 —— 当你盯着一段载荷却认不出它是什么时,这是个很好用的快速判断。
代价正是需要谨慎的理由。3 字节换 4 字符意味着在算上前缀之前就已经涨了 33%,而内联的字节无法被单独缓存、无法由 CDN 独立分发,并且随每一份包含它的文档被重新下载一次。它们还会撑大浏览器必须先解析完才能渲染的那份 HTML 或 CSS。
对体积小、内容稳定、且会阻塞渲染的资源,这笔交易划算:关键样式表里的一个图标、邮件模板里的一张占位图、自包含报告里的一枚 Logo。对照片、对用户可能替换的资源、对跨页面共享的资源,这笔交易很亏。如果一张图大到让你开始犹豫要不要内联,答案基本上就是不要。
输出长度恒等于字节数除以 3 向上取整再乘 4,这还没算上 Data URL 前缀。
| 输入大小 | Base64 字符数 | 实际膨胀 |
|---|---|---|
| 1 字节 | 4 | 4.00 倍 |
| 3 字节 | 4 | 1.33 倍 |
| 100 字节 | 136 | 1.36 倍 |
| 1 KiB(1024 字节) | 1368 | 1.34 倍 |
| 100 KiB(102400 字节) | 136536 | 1.33 倍 |
Base64 不是加密,而且谁都看得出来
解 Base64 不需要密钥、不需要口令、不需要授权。每种语言都自带解码器,每个浏览器控制台里都有一个,而那套字母表一眼就能认出来。末尾的等号、一长串没有空格的大小写混排字母、以 eyJ 开头且解码后是一个左花括号加引号的串 —— 任何常年和 API 打交道的人扫一眼就知道。
这件事之所以重要,是因为 Base64 经常出现在「看起来像保护、实际不是」的地方。HTTP Basic 认证发送的是 user:password 的 Base64,所以 Authorization: Basic QWxhZGRpbjpvcGVuIHNlc2FtZQ== 只是穿了件衣服的明文凭据,它安全只是因为底下垫着 TLS。JWT 的头部和载荷是 Base64URL 编码的 JSON,任何持有者都能读 —— 签名证明的是令牌由谁签发,而不是内容保密。为了不进日志而做成 Base64 的配置项,照样躺在日志里。
正确的理解是:Base64 回答的是传输问题,对机密性只字未提。如果一个值必须保密,就加密它,或者把它放进密钥管理系统;之后如果它还要穿过只认文本的通道,再对密文做 Base64。顺序别搞反,这就是全部要点。
- Authorization: Basic QWxhZGRpbjpvcGVuIHNlc2FtZQ== 解码后是字符串 Aladdin:open sesame。
- 以 eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9 开头的 JWT 段,就是 JSON 头部 {"alg":"HS256","typ":"JWT"},人人可读。
- 以 /9j/ 开头的 Data URL 是 JPEG,以 iVBORw0KGgo 开头的是 PNG,以 R0lGODlh 开头的是 GIF。
要点回顾
- 只在字节必须穿过「只承诺承载文本」的通道时才用 Base64,并预留大约三分之一的体积增长。
- 按目的地选变体:MIME 和 Data URL 用带加号斜杠的标准 Base64,任何进 URL 或令牌的场景用带连字符下划线的 Base64URL。
- 先把文本按 UTF-8 转成字节再做 Base64,解码时按相反顺序还原;浏览器里尤其要注意,btoa 遇到任何高于 U+00FF 的字符(包括每一个汉字)都会抛异常。
- 把来路不明的 Base64 值补齐到 4 的倍数再交给严格解码器 —— 补位不携带信息,但有些库就是要。
- 永远不要把 Base64 当成隐藏手段;在决定它能不能进日志、能不能外发、能不能提交之前,先用 Base64 文本工具自己解一遍。