[RFC] 8 min read

[RFC] URL-safe Base64 与标准 Base64

标准 Base64 和 URL-safe Base64 看着几乎一样,却会彼此破坏对方的解码器。本文精确说明差了什么、为什么要差,以及 JWT、OAuth、URL 里各自应选哪一个。

April 2026 | standards

// 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 的 statenonce 令牌
  • > • 魔法链接、邀请码、短链令牌
  • > • 可能被复制进 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 都不合适时怎么办。