[RFC] URL-safe Base64 与标准 Base64
标准 Base64 和 URL-safe Base64 看着几乎一样,却会彼此破坏对方的解码器。本文精确说明差了什么、为什么要差,以及 JWT、OAuth、URL 里各自应选哪一个。
// TL;DR
Base64 存在两种字母表,都在 RFC 4648 里定义。
• 标准 Base64(§4)——字符集为 A–Z a–z 0–9 + /,带 = 填充。在 MIME、PEM 文件、HTTP Basic Auth、邮件里是安全的。一旦丢进 URL、Cookie、文件名或 DNS 标签就会翻车。
• URL-safe Base64,有时写作 base64url(§5)——字符集为 A–Z a–z 0–9 - _,通常省略填充。在 URL、JWT 的各段、OAuth 的 state 令牌、Cookie、DNS、文件名里都安全。
针对其中一种字母表的解码器会拒绝另一种的输出。挑一个匹配你传输通道的变体,然后坚持用下去。
// 两种字母表对比
// Position 62 and 63 are the only differences
//
// Standard (RFC 4648 §4) URL-safe (RFC 4648 §5)
// 0–25 A-Z A-Z
// 26–51 a-z a-z
// 52–61 0-9 0-9
// 62 + - (hyphen)
// 63 / _ (underscore)
// pad = = or omitted
// Worked example: encoding 0xFB 0xFF 0xBF
// binary: 11111011 11111111 10111111
// 6-bit: 111110 111111 111110 111111
// values: 62 63 62 63
// Standard: + / + / → '+/+/'
// URL-safe: - _ - _ → '-_-_'
// 为什么需要一个 URL-SAFE 变体
标准字母表是为 MIME 正文设计的,MIME 非常宽松。URL 可不是。按 RFC 3986,+ 在某些场合会被解码为空格(这是从 HTML 表单继承下来的行为),/ 是路径分隔符,而 = 是保留的子分隔符。一旦你把三者俱全的标准 Base64 字符串放进 URL,不同解析器就会对它的含义各执一词。虽然百分号编码(+ → %2B、/ → %2F、= → %3D)从技术上能修好,但结果既臃肿又丑陋。
RFC 4648 §5 通过挑选两个在 URL 里没有特殊含义的 ASCII 字符来解决问题:-(连字符)与 _(下划线)。二者在 RFC 3986 里都是 unreserved,能原样通过所有 URL 解析器。填充 = 在某些 URL 上下文里也有问题(它是查询字符串里的键/值分隔符),因此 base64url 按惯例会把它省掉。
// 什么时候用哪一种
- > 标准 Base64——适用于:
- > • MIME 邮件附件(RFC 2045)
- > • PEM 格式的证书与密钥(RFC 7468)
-
>
• HTTP Basic Auth 头(
Authorization: Basic ...) -
>
• data: URI(
data:image/png;base64,...)——+/=在带引号的属性值里都合法 - > • XML-DSIG 与 XML-ENC 签名
- > • S/MIME
- > URL-safe Base64——适用于:
- > • JWT 的头、载荷、签名(RFC 7515 强制要求 base64url)
- > • OAuth 2.0 code_challenge / PKCE(RFC 7636)
-
>
• OpenID Connect 的
state与nonce令牌 - > • 魔法链接、邀请码、短链令牌
- > • 可能被复制进 URL 的 Cookie 值
-
>
• 由哈希生成的文件名——避免在文件名里出现
/! - > • DNS 标签与 TXT 记录(允许连字符,不允许斜线)
- > • 内容寻址存储的键(类似 IPFS 的系统)
// 在两种之间互转
既然两种字母表只在位置 62、63 以及填充上不同,转换无非是三字符的查找替换。下面给出你可能用到的每种运行时的写法。
// JavaScript (browser + Node)
const toUrlSafe = (b64) =>
b64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
const fromUrlSafe = (b64u) => {
let s = b64u.replace(/-/g, '+').replace(/_/g, '/');
while (s.length % 4) s += '='; // re-add padding
return s;
};
// Node 16+ native
const url = Buffer.from(data).toString('base64url');
const std = Buffer.from(url, 'base64url').toString('base64');
// Python
import base64
url = base64.urlsafe_b64encode(data).rstrip(b'=') # no padding
std = base64.b64encode(data)
// Go
import "encoding/base64"
url := base64.RawURLEncoding.EncodeToString(data) // no padding
std := base64.StdEncoding.EncodeToString(data)
// PHP
$url = rtrim(strtr(base64_encode($data), '+/', '-_'), '=');
$std = base64_encode($data);
// Java
String url = Base64.getUrlEncoder().withoutPadding().encodeToString(data);
String std = Base64.getEncoder().encodeToString(data);
// Shell (GNU coreutils 9+)
echo -n 'hello' | base64 --wrap=0 | tr '+/' '-_' | tr -d '='
// 填充问题
标准 Base64 始终用 = 把输出填充到 4 的倍数。URL-safe Base64 则通常省略填充(RFC 4648 §5 明确指出这是可选的)。JWT(RFC 7515)完全禁止填充——eyJhbGciOiJIUzI1NiJ9 没有任何末尾的 =。OAuth code challenge(RFC 7636)同样禁止填充。
解码器必须同时能处理带填充与不带填充两种情况。宽容的解码器会剥掉末尾的 =,并在内部按长度是否是 4 的倍数自行补齐。严格的解码器会拒绝任何没有对齐到 4 的输入。如果你在写编码器,请匹配接收方的预期;如果你在写解码器,请宽容一些。
对未填充的 base64url 解码时该补多少 =?规则是:输入长度对 4 取模,如果等于 2,补两个 =;等于 3,补一个 =;等于 0,不用补;等于 1,则输入是非法的(没有任何合法 base64 的长度 mod 4 会是 1)。
// Restore padding before decoding a JWT segment
function addPadding(b64u) {
const mod = b64u.length % 4;
if (mod === 2) return b64u + '==';
if (mod === 3) return b64u + '=';
if (mod === 0) return b64u;
throw new Error('Invalid base64url length');
}
// 混用了会出什么事
-
>
静默的数据损坏——用标准解码器喂入 URL-safe 载荷时,大多数字符都能正确解码,但
-和_不在它的字母表里。根据实现不同,它可能抛错、可能把它们当作垃圾跳过、也可能悄悄产生错误的字节。 - > 长度不匹配——解码器看到一个长度不是 4 的倍数的字符串(因为填充被丢掉了),直接拒收。这是期望带填充输入的 JWT 库里一个常见痛点。
-
>
百分号编码意外——你不小心把标准 Base64 放进了 URL,中间件又只对
+和=做了百分号编码,于是你得到一份编码混用的载荷:对部分解码器来说合法,对另一些来说就是垃圾。 -
>
Cookie 被拒——有不少 HTTP 服务器会拒收值里含
/或=的 Cookie,哪怕规范其实允许。用 URL-safe Base64 能彻底绕开这个问题。 -
>
Windows 文件名失败——标准 Base64 输出里可能出现
/,它是路径分隔符。用 SHA-256 的标准 Base64 命名文件,在 Windows 和 POSIX 上都会失败。 -
>
复制粘贴损坏——某些终端和聊天工具会在
/处自动换行,悄无声息地把一个标准 Base64 字符串切断。URL-safe 不会触发这种行为。
// 实战案例:JWT
一个 JSON Web Token 形如 header.payload.signature,是三段用点分隔的 base64url 字符串。所有主流 JWT 库(jose、jsonwebtoken、PyJWT、golang-jwt、java-jwt)都使用不带填充的 base64url 编码,因为这些令牌经常会被放进 URL 和 HTTP 头。
如果你在浏览器里用 atob() 手工解码一个 JWT,必须先做转换:把 - 替换成 +、_ 替换成 /,并补回缺失的 =。像 jose 这样的库内部已经处理好了这些。
// Decode a JWT payload in the browser
function decodeJwt(token) {
const [, payloadB64u] = token.split('.');
let b64 = payloadB64u.replace(/-/g, '+').replace(/_/g, '/');
while (b64.length % 4) b64 += '=';
const json = atob(b64);
return JSON.parse(json);
}
// Example JWT payload
decodeJwt('eyJhbGciOiJIUzI1NiJ9' +
'.eyJzdWIiOiJhYmMxMjMifQ' +
'.sig').sub; // 'abc123'
// DATA URI:一个边界情形
Data URI(data:image/png;base64,...)技术上属于 URL,但根据 RFC 2397,它接受完整的标准 Base64 字母表,包括 + 和 /。这就是你到处看到 data URI 里是标准 Base64 的原因——这正是规范。
在 data URI 里用 URL-safe Base64 其实违反了 RFC 2397,严格的解析器可能会拒绝。data URI 请坚持使用标准 Base64。URL-safe 只有在 data URI 本身被当作查询参数嵌进另一个 URL 时才可能重要——而这时候更合适的做法是对整个 URI 再做一次百分号编码。
// BASE64URL 不是 URL 编码
有必要专门说明这一点,因为名字容易混淆。URL 编码(也叫百分号编码,RFC 3986)是把保留字符转成 %XX 序列——你在浏览器地址栏输入空格变成 %20 就是它干的。
URL-safe Base64 则是一种 Base64 风味,设计的目标就是让输出根本不需要再做 URL 编码。它产出的字符串天然就是 URL 安全的,因此不需要百分号编码。你永远不会把两者串联起来用;而是根据数据选择其中一种。
经验法则:如果数据是二进制(图片、哈希、签名)并且要放进 URL,用 base64url。如果数据本身已经是文本(查询参数值、表单字段),用百分号编码。
// 决策树
- > 这串 Base64 要进入 URL、Cookie 值、文件名、DNS 标签、JWT 或 OAuth 流程吗?→ URL-safe Base64。
- > 要进入邮件正文、PEM 文件、HTTP Basic Auth 头、data URI 或 MIME multipart 吗?→ 标准 Base64。
- > 消费方是你无法控制的一个库(JWT 解析器、OAuth 提供方、S/MIME 客户端)吗?→ 匹配对方的期望;读规范或库的文档。
- > 不确定?→ 用不带填充的 URL-safe。它是最通用的变体;真要给严格的接收方用,事后再补上填充就好。
// 动手试试
我们的 Base64 编码器默认输出标准 Base64,并提供切换到 URL-safe 输出的开关。我们的 base64url 编码器默认输出不带填充的 URL-safe。把它们并排看一下,哪些字符变了就一目了然。
延伸阅读:
• Base64 是什么?它是如何工作的?——两种变体共同依赖的 6-bit 分组机制。
• JavaScript(浏览器 + Node)中的 Base64——面向各运行时的辅助函数。
• Base64、Base64URL 与 Base32 对比——当两种 Base64 都不合适时怎么办。