Base64 天天在用,但它真的不是加密

2026-05-08编码转换
分享到

前几年审过一个老项目的登录接口。后端把密码 Base64 一下存进数据库,注释写着"密码加密"。我说这不是加密,负责人不服气:存进去一串看不懂的 MTIzNDU2,拿出来解码就还原,这不是加密是什么?

我现场给他演示:打开浏览器控制台,atob("MTIzNDU2") 回车,123456 直接出来了。加密需要密钥,解码不需要——Base64 从头到尾没有密钥这回事,任何人拿到密文都能原样还原。这串字符能"看不懂",只是因为你不认识它,跟加密的"看不懂"是两码事。

那 Base64 到底是干什么的?一句话:把任意字节流变成纯文本可安全传输的字符。它解决的是"传输层不认二进制"的问题,不是"防止别人看"的问题。

为什么要多此一举

你可能会问:字符串本来就能传,为什么还要编码?

问题出在任意字节上。一张图片、一个 zip 包、一段密文的原始字节里,几乎必然出现各种控制字符(0x00-0x1F)、大于 0x7F 的高位字节。这些字节在很多协议里走不通:

  • 早期 SMTP 邮件协议只保证 7 位 ASCII 可靠传输,高位字节会被网关改写或丢弃,邮件附件因此烂掉;
  • HTTP 头、URL、Cookie、JSON 字符串这些"文本容器"里,某些字节有特殊含义(比如换行、引号),原样塞进去会破坏结构;
  • 很多文本处理系统对非法 UTF-8 字节序列没有定义行为,字节流一进去就变了样。

所以思路是:把 8 位的字节流,重新打包到 64 个"绝对安全"的字符上——大小写字母、数字、+ 和 /,一共 64 个,全部是打印字符,在任何文本协议里都不会被特殊对待。这就是名字里"64"的来历。

3 字节怎么变 4 字符

Base64 编码流程

64 个字符,每个字符携带 6 比特信息(2⁶ = 64)。而字节的单位是 8 比特。8 和 6 的最小公倍数是 24——正好 3 个字节(24 位)可以整齐切成 4 组(每组 6 位)。

拿 RFC 4648 里的经典例子 Man 走一遍:

  1. 三个字节:M = 0x4D = 01001101,a = 0x61 = 01100001,n = 0x6E = 01101110;
  2. 拼成 24 位:010011010110000101101110;
  3. 切成 4 组 6 位:010011 010110 000101 101110,十进制分别是 19、22、5、46;
  4. 查字母表:19 → T,22 → W,5 → F,46 → u。

结果就是 TWFu。字母表顺序是 A-Z(0-25)、a-z(26-51)、0-9(52-61)、+(62)、/(63)。

从这个机制能直接推出 Base64 的两个先天性质:

体积必然膨胀 33%。3 字节变 4 字符,每字节从 8 位膨胀到 10.67 位(4/3 倍)。这不是实现问题,是数学上限——用 64 个可打印字符承载任意字节,代价就是这么多。有人以为换更好的库能压下来,不存在的,编码方式本身决定了下限。

同样的输入永远得到同样的输出。Base64 是纯函数,没有随机性、没有密钥。所以同一张图片转两次 Base64 结果一模一样——这也是它能被用来做完整性对照的原因。

Padding:最后几个字节怎么办

不是每次都那么巧,数据长度正好是 3 的倍数。剩下的零头怎么处理,靠 = 补位:

Base64 Padding 规则

剩余 1 个字节时补 4 个 0,凑成 2 组 6 位,输出 2 个字符再加 ==;剩余 2 个字节时补 2 个 0,输出 3 个字符再加一个 =。所以看到 TQ== 就知道原文最后只剩 1 个字节——等号的数量直接泄露了原文的长度信息,这也是它不算加密的又一个佐证。

编码有 =,解码就靠它还原长度。但有个反直觉的事实:去掉 = 也能解码。因为解码器拿到 4 的余数就能算出原文长度,= 只是为了格式规整。JWT 就是这么干的——它的三段 header、payload、signature 都用 base64url 编码,且不带 padding,这样放进 token 里更干净。

URL 里放不下,于是有了 base64url

标准字母表里的 + 和 / 有个致命问题:放到 URL 里会出事。+ 会被表单解析成空格,/ 是路径分隔符。JWT、URL 参数里的 Base64 都得绕开这两个字符。

RFC 4648 给出了变体 base64url:+ 换成 -,/ 换成 _,其他不变。JWT 的三段式结构用的就是它。判断一段 Base64 来自哪里,看这两个位置的字符是个快速线索:出现 -_ 的大概率是 base64url,出现 +/ 的是标准 Base64。

还要警惕双重编码。我修过一个 bug:前端把一个已经是 Base64 的字符串又编码了一次,接口拿到的数据解码后还是一串 Base64。判断方法很简单——解一次之后看看开头,如果还是一串 Base64 的样子(长度是 4 的倍数、字符集吻合),大概率就是编码了两次。URL 编码的"只在出口编一次"原则在这里同样适用。

btoa 的中文乱码陷阱

浏览器原生的 btoa 有个著名的坑:它只接受 Latin1 范围(码点 0-255)内的字符。传个中文进去直接抛错:

btoa("船长")        // 抛 InvalidCharacterError
btoa("hello")       // "aGVsbG8=",没问题

因为 Base64 操作的对象是字节,而 JavaScript 字符串是 UTF-16 码元。"船长"是两个字符、四个字节(UTF-8),btoa 不知道该按什么字节序列处理,索性报错。

正确做法是先转成 UTF-8 字节再编码:

// 用 TextEncoder,现代且无歧义
function utf8ToBase64(str) {
  const bytes = new TextEncoder().encode(str);
  let bin = "";
  bytes.forEach(b => bin += String.fromCharCode(b));
  return btoa(bin);
}

utf8ToBase64("船长");  // "6LWk6YCx",正确

解码方向同理,atob 出来的也只是字节序列,中文要先按 UTF-8 解码:new TextDecoder().decode(bytes)。网上流传的 btoa(unescape(encodeURIComponent(s))) 写法本质是同一件事的旧语法版本,能跑但难读。

Node 里简单得多:Buffer.from(str, "utf-8").toString("base64"),一步到位,还比 btoa 快。

Data URI:小图内联的正确姿势

前端最常见的 Base64 应用是把图片转成 Data URI 直接写进 CSS 或 HTML:

.icon-close {
  background-image: url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0...");
}

好处是省掉一次 HTTP 请求。10×10 的小图标、按钮背景这种几百字节的小资源,内联确实更快。

但别把大图也这么干。Base64 内联的图有三个隐性代价:

问题说明
体积膨胀 33%100KB 的图转完变 133KB,还要挤在 HTML/CSS 里
失去独立缓存图片跟着页面走,页面更新一次用户就重下一次
阻塞解析内联在 CSS 里的资源无法并行加载和懒加载

经验值:小于 2KB 的图标内联,大于 2KB 的老老实实走独立文件。具体边界可以拿 在线图片转 Base64 工具 转换后对比一下实际体积再决定。

解码时的几个坑

最后整理几个我在实际解码时踩过的坑:

分隔符和空白字符。邮件里的 Base64 每 76 个字符会插一个换行(RFC 2045 的规定),JSON 里有时混进空格。浏览器 atob 能容忍空白字符,但 Node 的 Buffer.from(s, "base64") 会把非法字符直接跳过——两种行为都"能跑",结果可能不一样,遇到解码结果怪异先检查输入是否干净。

大小写敏感。Base64 的字母表里大小写是不同字符,aGk= 和 AGi= 完全是两个东西。从截图抄编码时最容易错的就是大小写和 l/I/1、O/0 这几组形近字符。

pad 缺失。一些系统输出的 Base64 把 = 去掉了(比如 JWT),拿去喂严格要求补位的解码器会报错。解码前把长度补到 4 的倍数即可:s += "=".repeat((4 - s.length % 4) % 4)。

别拿 Base64 做"混淆"。见过把 API key Base64 之后硬编码进前端代码的,以为这样爬虫就看不到了。爬虫看到 eyJ 开头的长字符串,比谁都清楚那是 Base64。需要藏的东西放服务端,这是唯一正解。

想动手验证本文的例子,可以用 在线 Base64 编解码工具,支持标准字母表和 URL-Safe 变体的双向转换,粘贴进去立刻出结果。

评论