需要使用 Base64 格式吗?那么本网站正好适合您!使用我们的在线工具对数据进行编码或解码,便捷好用。

PHP 中的 Base64 解码:完整指南

它出现在工单里、API 日志里、配置文件里,或者一个 URL 的正中间:一长串字母、数字,偶尔夹杂 +/,末尾可能还有一两个 =。你一眼就能认出来。Base64 是一种二进制到文本的格式:它把原始数据每三个字节重写成四个字符,取自一个 64 字母的字符表;当字节数不是三的倍数时,就有一两个 = 给末尾收尾。解码正是这笔交易里缩水的那个方向:四个字符进去,三个字节出来。本站首页已经一步步讲过这个格式,所以这篇文章把力气花在它该在的地方:PHP 这一侧的活儿。

先说重点。PHP 从 PHP 4 开始,核心里就自带了一个 Base64 解码器。base64_decode() 不需要扩展、不需要 Composer 包、也不需要任何配置,PHP 能在哪里运行,它就能在哪里运行。没那么好的消息是:它的默认脾气会默默吞下损坏的输入,一言不发地把垃圾递给你。好消息还会更好:一个标志($strict)就能让这个函数变成一位称职的看门人。一旦你知道怎么挑选脾气、怎么证明输入是真的、怎么把字节重新变成含义,Base64 就不再是玄妙 bug 的来源,而会成为一道你可以自动化的常规流程。

快速提醒一句尺寸问题:解码会把数据缩小大约四分之一(每进去四个字符,出来三个字节),所以输出永远比输入占更少的内存。你从不用担心一次解码把内存撑爆。现在,让我们来认识一下这件工具。

那个干活的函数

下面是完整的函数签名,正是现代 PHP 报告的样子:

base64_decode(string $string, bool $strict = false): string|false

那一行里有三个词干完了所有的活儿。$string 没有大小限制:一兆字节在一毫秒以内就能解码完,所以没有任何理由阻止你一次调用解码整个文件。返回类型道出了整个约定:要么是一串解码后的字节,要么是 false。没有异常,没有错误码,也没有第二条通道。false 是你唯一能得到的信号,所以检查它是工作的一部分。手册里有一句话值得背下来:返回的数据可能是二进制。一旦结果里装着 PNG、ZIP 或哈希,它在任何宽泛意义上都不是"文本字符串",而 PHP 会爽快地纵容你继续把它当字符串用。这份灵活性既是超能力,也是陷阱,下面的章节会把它管住。

快速过一遍版本标签,因为接手来的代码总爱擅自假设。这个函数从 PHP 4 起就在核心里。它的 $strict 参数出现在 PHP 5.2.0,那是 2006 年 11 月的事。从 PHP 8.0 开始,签名上挂上了真正的原生类型(上面看到的 stringbool,再加上 string|false 返回值),IDE 和静态分析器终于知道这个函数会失败了。从 PHP 8.1 开始,传入 null 会触发弃用通知;如果你想表达"什么都没有",就显式地写 ''

$decoded = base64_decode('');
var_dump($decoded); // string(0) ""

严格模式还是静默清理

$strict 标志是在两种截然不同的性格之间切换的开关。关着(默认)的时候,解码器是个和善的健忘鬼:每个不在 Base64 字母表里的字符都被悄悄丢掉,剩下的照常解码,谁也不告诉。手册说得直白:否则,无效字符将被静默丢弃。开着的时候,解码器是一位看门人:它遇到的第一个认不出的字符,就足以让整段载荷领到一份 false

下面是损害报告。下面每一行都是 base64_decode() 在 PHP 8.x 上的真实行为:

输入 宽松模式(默认) 严格模式
Zm9vYmFy,干净 "foobar" "foobar"
Zm9v\r\nYmFy,字符串中间有 CRLF "foobar" "foobar"
" Zm9vYmFy ",两端有空格 "foobar" "foobar"
Zm9v\x0bYmFy,垂直制表符 "foobar" false
Zm9v\x00YmFy,内嵌 NUL 字节 "foobar" false
V@hpcy,一个多余的 @ 3 字节垃圾 false
Zm9vY,五个字符 "foo",最后一个字符被丢弃 false
Z,单个字母 "",空字符串 false
=Zm9,开头就带填充 "fo" false
Zm9vYmFy==,完整分组后还带填充 "foobar" false
Zm9vYmFy==A,填充后面还有数据 "foobar" false
Zm9vYmF,七个字符,无填充 "fooba" "fooba"

有三行值得再看一眼。V@hpcy 那一行说明了为什么在输入不可信的场合,宽松模式很危险:那个多余的 @ 并没有让解码停下来,它只是消失了,而解码出来的那三个字节毫无意义。单个 Z 那一行说明,空结果几乎证明不了什么:一个字符的载荷"解码"成了一个空字符串,全程没有失败。而 Zm9vYmFy==A 那一行说明,解码器会乐呵呵地忽略出现在填充后面的数据 - 被截断或被篡改的载荷,正是靠这个看起来完好无损。

严格模式还会放过什么?恰好是四个空白字符:空格、制表符、回车和换行,出现在任何位置都可以,甚至紧挨着 = 号。这是刻意为之。MIME 包装的邮件载荷在编码流内部带有 CRLF 换行,严格模式不需要任何预处理就能把它们嚼过去(下面的邮件章节会解释原因)。其余所有不属于字母表的字符,从 NUL 字节到垂直制表符,统统领到一份 false

还有一种货真价实的宽容值得知道,虽然它不是 PHP 的招牌毛病:PHP 会默默替你补上缺失的填充。七个字符的载荷 Zm9vYmF(完全没有填充)解码成 "fooba",和带填充的表哥 Zm9vYmF= 一模一样,两种脾气下都如此。RFC 4648 在一般情况下要求填充,所以接受无填充的尾巴是一种刻意的放宽,而且这并非 PHP 独有:Go 的 RawStdEncoding 和 Java 的解码器也接受同样的无填充输入。如果你的 PHP 端和某个合作系统对一个边界情况载荷各执一词,通常就该先去查填充是否缺失。

标准也站在严格脾气这一边。RFC 4648 第 3.3 节指出,实现必须拒绝含有字母表之外字符的编码数据,除非外围规范另有说明(MIME 就是经典的"另有说明"的情形)。同一节还解释了原因:字母表之外的字符可以被利用成隐蔽信道,把信息藏在你那解码器会丢弃的字符里,而且它们确实被用来触发过解码器 bug。如果你的输入来自外部世界,严格模式就不是风格选择,而是标准的要求。

证明载荷是 Base64

一个会悄悄失败的解码器,值得在它前面放一条验证流水线。共三层,每层都接住其他层漏掉的东西。

第一层是用正则表达式做的形状检查:只允许字母表字符,而且末尾最多两个填充。

$shapeLooksPlausible = preg_match('/^[A-Za-z0-9+\/]*={0,2}$/', $payload) === 1;

正则会先在其他东西跑起来之前接住明显的垃圾(多余的空格、@ 号、字符串中间的填充)。不过它并不是校验器:它看不出 Zm9vYmFy= 是九个字符却只有一个填充,而严格模式同样会拒绝它。这正是第二层存在的原因。严格解码是唯一理解 Base64 语义的检查,所以最终裁决权归它。

第三层是大家都会忘的那层:显式处理 false,因为它是你能得到的唯一信号。

function decode_payload(string $payload): string
{
  $clean = str_replace(["\r", "\n"], '', $payload);
  $decoded = base64_decode($clean, true);
  if ($decoded === false) {
    throw new InvalidArgumentException('Not a valid Base64 payload.');
  }
  return $decoded;
}

开头那个 str_replace() 是可选的安慰:严格模式本来就容忍 CRLF,但把它剥掉能让你之后做的长度计算保持干净,因为干净载荷的字符数总是四的倍数。(比四的倍数多一,比如五个或九个,在 Base64 里是不可能的,严格模式也会拒绝它。)注意这个函数自己从不抛异常;检查得由你来写。

URL 安全的 Base64

在真实世界里你会遇到第二套字母表,而且它就是会咬人的那种。标准 Base64 使用 +/,这两个字符在 URL 里都是麻烦:查询字符串里的 + 在 PHP 看到它之前就被解释成空格了,而 / 是路径分隔符。RFC 4648 第 5 节定义了补救方案:URL 和文件名安全字母表,其中 + 变成 -/ 变成 _,末尾的 = 填充通常直接省掉以节省字符。RFC 态度坚决,这"不应被视为与 base64 编码相同",而你会听到最多的名字是 base64url。JSON Web Token、OAuth 的 state 参数、API 会话 ID 和视频网站的 URL 都住在这个方言里。

解码这边是两步:先把字母表换回来,再把缺失的填充补上。下面是你最终会在到处复用的助手函数:

function base64url_decode(string $data): string|false
{
  $standard = strtr($data, '-_', '+/');
  $missing = strlen($standard) % 4;
  if ($missing !== 0) {
    $standard .= str_repeat('=', 4 - $missing);
  }
  return base64_decode($standard, true);
}
var_dump(base64url_decode('aGk_PnRoZXJl')); // string(9) "hi?>there"

现代 PHP 在这件事上是站在你这边的:它会替你补上缺失的填充,所以显式补填充属于双保险(也让你的代码可以移植到更老的 PHP 版本)。危险的方向是单向的。如果你把 URL 安全的文本喂给宽松模式下的标准解码器,-_ 这两个字符根本不在标准字母表里,于是被直接丢弃。你的输出会比应有的短一截,没有错误,没有通知,什么都没有。永远先跑 strtr() 这次替换,更好的做法是永远走那个助手函数。

一个坦率的提醒:如果某个 URL 安全载荷恰好既不含 - 也不含 _,那么对这份特定数据来说,两套字母表逐字节相同,用哪个解码器都无所谓。只有当这些字符出现时,危险才会出现,因为那是两套字母表唯一不同的地方。

文本、字节与字符集

Base64 完全不知道你的字节是什么意思,PHP 的解码器也继承了这份盲目。这个编解码器对字符集是瞎的:不管进来的是 UTF-8 文本、Windows-1252 文本、JPEG 还是哈希,它还手的都是原样的 8 位值。PHP 本身也持相同立场:字符串就是一串字节,仅此而已。一旦你想显示结果或把它跟其他文本比较,就必须有人回答两个问题:这到底是不是文本,如果是,又是哪个字符集?

实用的判断办法分两个桶。二进制几乎总是用 NUL 和低位控制字节自我宣告,不是合法 UTF-8 的文本则属于第二个桶。mbstring 扩展(默认不启用)给你严格的 UTF-8 检查:

function looks_binary(string $bytes): bool
{
  if ($bytes === '') {
    return false;
  }
  if (strpbrk($bytes, "\x00\x01\x02\x03\x04") !== false) {
    return true;
  }
  return !mb_check_encoding($bytes, 'UTF-8');
}
var_dump(looks_binary("\x89PNG\r\n\x1a\n...png body")); // bool(true)
var_dump(looks_binary("héllo wörld, 日本語"));          // bool(false)

当载荷是某个遗留字符集的文本时,在它碰到你的 HTML 之前先转换它。Windows-1252 是网页和桌面数据中最常见的遗留编码,它和纯 ISO-8859-1 之间的差别,决定了字节 0x93 到底是一个弯引号还是一个不可见的控制字符:

// Windows-1252 里的 "café":é 是单个字节 0xE9
$legacy = base64_decode('Y2Fm6Q==', true);
$utf8 = mb_convert_encoding($legacy, 'UTF-8', 'Windows-1252');
var_dump($utf8); // string(5) "café":é 现在是两个 UTF-8 字节

对大名鼎鼎的 mb_detect_encoding() 要提个醒:PHP 手册自己都说自动检测"永远无法完全可靠",还把它比作没有密钥就去解密一条消息。喂给它一个 Windows-1252 的 "café",它可能说出 Windows-1252;喂给它一个 PNG 头,它又能乐呵呵地再说一遍 Windows-1252,因为 ISO-8859 家族字符集对每一种可能的字节值都有定义,所以什么都能匹配。把检测当作最后的退路;只要存在声明过的字符集(一个响应头、一行配置、一个数据库排序规则),就相信声明;剩下的默认按 UTF-8 或二进制处理。

当载荷是一个文件时

最常见的文件活儿是某个导出流程的逆过程:一个 .b64 文本文件送到了,你需要把原始文件拿回来。有了严格解码和 false 检查,这段代码已经具备生产级形态:

$encoded = file_get_contents('/var/www/uploads/blob.b64');
$decoded = base64_decode($encoded, true);
if ($decoded === false) {
  http_response_code(400);
  exit('That upload is not valid Base64.');
}

PHP 字符串就是字节,所以这条路径上的任何东西都不在乎载荷是文本文件、ZIP 归档还是视频。尺寸计算对你有利:解码后的输出是编码输入长度的四分之三,所以解码永远不会让内存状况变差。

一个好习惯是:在相信任何标签之前,先让字节自我宣告。finfo 类(fileinfo 扩展,随标准 PHP 构建一起捆绑)会告诉你数据实际上是什么:

$mime = (new finfo(FILEINFO_MIME_TYPE))->buffer($decoded);
var_dump($mime); // string(9) "image/png"
$extensions = ['image/png' => 'png', 'application/pdf' => 'pdf', 'application/zip' => 'zip'];
$ext = $extensions[$mime] ?? 'bin';
$target = '/var/www/uploads/file-' . bin2hex(random_bytes(4)) . '.' . $ext;
file_put_contents($target, $decoded);

最后那一步比看上去更重要。一个自称是图像、解码后却是别的东西的载荷,正是第二意见能抓住的那类问题。而且如果你之后要把恢复出来的文件再发回给浏览器,你发送的 Content-Type 应该来自同一个 finfo 检查,而不是来自文件名。

Data URI,剪贴板格式

一种常客:某人往表单里粘贴了一张图片,前端递给你一个完整的 data URI:data:image/png;base64,iVBORw0KGgo...。RFC 2397 定义了它的形状:data:,可选的媒体类型,可选的 ;base64 标志,一个逗号,然后是数据。标志在的时候,载荷是 Base64;标志不在的时候,载荷是百分号编码的纯文本,更少见但同样合法。如果省略媒体类型,默认值是 text/plain;charset=US-ASCII。这里为什么非要用 Base64?因为 URI 无法安全地包含原始字节或逗号,而 Base64 给你一套完全不需要转义的字母表。

function split_data_uri(string $uri): ?array
{
  if (!str_starts_with($uri, 'data:') || !str_contains($uri, ',')) {
    return null;
  }
  $meta = substr($uri, 5, strpos($uri, ',') - 5);
  $payload = substr($uri, strpos($uri, ',') + 1);
  $isBase64 = str_ends_with($meta, ';base64');
  $mime = $isBase64 ? substr($meta, 0, -7) : $meta;
  if ($mime === '') {
    $mime = 'text/plain;charset=US-ASCII';
  }
  return [$mime, $isBase64, $payload];
}
$uri = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJ';
[$mime, $isBase64, $payload] = split_data_uri($uri);
var_dump($mime); // string(9) "image/png"

这个格式里住着两个陷阱。第一个是缺失的 ;base64 标志:一个没有该标志的合法 data URI 携带的是百分号编码的载荷,拿它去跑 base64_decode() 只会得到垃圾。第二个是声称的媒体类型:它来自发送方,是提示,不是事实。文件章节里那个 finfo 检查才是你的事实。还要记住 RFC 自己的建议:data URI 只对短值有用;一个几兆字节的图像塞在 URL 里,是臭味,不是模式。

JWT:可以偷看的令牌

网络上最有名的 Base64 载荷是 JSON Web Token,而一旦你知道了它的形状,它也是最少吓人的那种。按 RFC 7519,紧凑格式的 JWT 是三个用点分隔的 URL 安全 Base64 部分:头部、载荷和签名,每一部分都编码得没有填充、没有换行(RFC 7515 明确说明不允许任何额外字符混进来)。头部和载荷是纯 JSON,这就是为什么人人都能读懂它们,也是为什么每个人在动手碰令牌之前都应该先看懂下一段。

用上面的助手函数读取前两部分只需五行代码,这也是让令牌去神秘化的一大妙法:

$token = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.8GljXWCrvkTYln_WtTVhyWSzflOC1iGL8jBDUHQmaEE';
[$headerPart, $payloadPart] = explode('.', $token);
$header  = json_decode(base64url_decode($headerPart), true);
$payload = json_decode(base64url_decode($payloadPart), true);
var_dump($header);
// array(2) { ["alg"] => string(5) "HS256" ["typ"] => string(3) "JWT" }
var_dump($payload);
// array(3) { ["sub"] => string(10) "1234567890" ["name"] => string(8) "John Doe" ["iat"] => int(1516239022) }

现在说要紧的部分:第三部分是签名,而你刚解码出来的那两部分既不机密、也没有被认证。任何拿着抓包工具的人都能读懂它们,任何拿着文本编辑器的人都能改写它们。在验证签名之前就相信载荷,是经典的 JWT bug。生产环境里,不要手写这个检查。社区的答案是 firebase/php-jwt 包,目前是 v7 版本,符合 RFC 7519,要求 PHP 8.0 或更高。用 Composer 安装它:

composer require firebase/php-jwt

然后这套 API 先验证,只有签名通过时才把载荷交到你手上:

use Firebase\JWT\JWT;
use Firebase\JWT\Key;
$secret = 'correct-horse-battery-staple-long-enough-secret';
try {
  $claims = JWT::decode($token, new Key($secret, 'HS256'));
  var_dump($claims->sub); // 是一个属性,而且只有在签名验证通过之后才有
} catch (UnexpectedValueException $e) {
  // 格式错误的令牌、签名无效,或声明已过期
}

一个版本说明:该库的 v7 对 HMAC 算法强制最小密钥长度,所以短于 32 字节的 HS256 密钥会在签名验证之前就因 DomainException 被拒绝。让你的密钥保持足够长;这个库不让你忘记这一点。

留意那套 API 的顺序:JWT::decode() 遇到签名无效、令牌过期或缺少算法时会抛异常,而不是返回垃圾,所以你能接到的载荷就是你敢信任的载荷。上面手写的那个版本是为了理解,也为了偷看那些本不打算给你的令牌;而库,才是用来信任的。

HTTP Basic 认证,最古老的请求头

网络上最古老的认证请求头至今还骑在 Base64 上。按 RFC 7617,HTTP Basic 请求发送 Authorization: Basic,后面跟着 username:password 的 Base64 编码。RFC 明确说明这是编码,不是保护:任何拿着抓包工具的人都能一键解码出前后两半。你在解码这一侧的活儿是:解析请求头、严格解码,并用一个时序安全的函数做比较。

function basic_credentials(string $header): ?array
{
  if (!str_starts_with($header, 'Basic ')) {
    return null;
  }
  $decoded = base64_decode(substr($header, 6), true);
  if ($decoded === false || !str_contains($decoded, ':')) {
    return null;
  }
  [$user, $password] = explode(':', $decoded, 2);
  return [$user, $password];
}
$header = $_SERVER['HTTP_AUTHORIZATION'] ?? '';
$creds = basic_credentials($header);
if ($creds !== null
  && hash_equals('alice', $creds[0])
  && hash_equals('secret123', $creds[1])
) {
  // 已认证
}

两个细节让这件事保持安全。explode() 里的限制值 2 很重要,因为密码里可以合法地包含冒号;比较应该用 hash_equals(),永远不要用 ==,这样攻击者就无法靠计时摸清你的用户列表。而且只在 HTTPS 上提供这项服务;在明文连接上,Base64 这层只是橱窗装饰。

电子邮件,一切开始的地方

Base64 是为一个具体问题而生的:邮件传输只搬得动 7 位 ASCII,但人们又想发送二进制。MIME 标准(RFC 2045 第 6.8 节)把 Base64 列为二进制传输编码之一,并加了两条家规。第一,编码后的行不得超过 76 个字符。第二,解码软件必须忽略字母表之外的每个字符,换行符也算。正是第二条家规,让 PHP 的解码器无论处于哪种脾气,都能直接嚼完一个 CRLF 包装的载荷,不需要你做任何预处理。(这也是上面严格模式表格里 \r\n 容忍度的来历。)

$png = "\x89PNG\r\n\x1a\n" . random_bytes(256);
$wrapped = chunk_split(base64_encode($png), 76, "\r\n");
// 稍后,在接收端,无需清理:
$decoded = base64_decode($wrapped, true);
var_dump($decoded === $png); // bool(true):每个字节都完整往返

两条实用备注。第一,包装会增加重量:每 76 个字符一个 CRLF 的情况下,一个 100 KB 的附件到达时大约是 137 KB 的文本(照例的三分之四膨胀系数,外加换行开销)。第二,对于带请求头、多个部分、还有 quoted-printable 同胞的真实邮件,可选的 mailparse 扩展能把完整的 RFC 822 消息逐部分解剖;而只要是一个已知附件,严格解码就够用了。

PEM 护甲:密钥与证书

证书和密钥穿着 PEM 护甲出行:一个 BEGIN 标签,一块按 64 个字符一行排列的 Base64,再加一个 END 标签。64 字符的行长是从最初的 Privacy Enhanced Mail 规范(RFC 1421)继承下来的约定,OpenSSL 工具都指望这个行长,所以你要重新封装护甲时它很重要。而你解码时,它一点都无所谓:解码器干脆忽略换行。

$pem = file_get_contents('/etc/ssl/my-key.pem');
preg_match('/-----BEGIN ([A-Z ]+)-----\s*(.*?)\s*-----END \1-----/s', $pem, $m);
$label = $m[1];
$der = base64_decode(preg_replace('/\s+/', '', $m[2]), true);
if ($der === false) {
  // 结果还不是 Base64
}
var_dump($label); // string(11) "PRIVATE KEY"

解码出来的字节是 DER,一种紧凑的二进制序列化,openssl_* 函数最终打交道的正是它。正则里那个反向引用 \1 是默默立功的英雄:它保证 END 标签与 BEGIN 标签匹配,从而避免在一个文件包含多个块时,把某个证书的 END 缝到某把密钥的 BEGIN 上。

流与大载荷

解码是帮你省的方向:输出只有输入的四分之三大,所以来自 Base64 的内存压力很少见。不过,当一个几百兆字节的 .b64 文件落到磁盘上时,你手头有两件工具可以把占用压平。

第一件是分块解码。把清理后的输入切成长度都是四的倍数的片段,每一片严格解码,然后拼接起来。每个分块都是自包含的合法载荷,所以边界上不会丢任何东西,而损坏的文件会快速失败,并给你一个可以上报的偏移量。

$clean = str_replace(["\r", "\n"], '', file_get_contents('/var/www/uploads/huge.b64'));
$decoded = '';
$chunkSize = 4 * 50000; // 四的倍数个字符,每次调用大约输出 150 KB
for ($offset = 0; $offset < strlen($clean); $offset += $chunkSize) {
  $part = base64_decode(substr($clean, $offset, $chunkSize), true);
  if ($part === false) {
    exit('Corrupted payload near offset ' . $offset);
  }
  $decoded .= $part;
}

在现代硬件上,一兆字节的 Base64 解码远不到一毫秒,所以这个循环几乎不花什么成本;选它是为了它的验证和上报特性,不是为了速度。

第二件工具是流式世界的一员:convert.base64-decode 流过滤器。它适用于任何 PHP 流,所以你可以直接从文件指针、php://input 或内存流里解码,而从不把整个编码文本装进一个变量。和宽松的函数一样,它只是跳过 Base64 字母表之外的每个字符:

$in = fopen('/var/www/uploads/huge.b64', 'rb');
$out = fopen('/var/www/uploads/huge.bin', 'wb');
stream_filter_append($in, 'convert.base64-decode', STREAM_FILTER_READ);
stream_copy_to_stream($in, $out);
fclose($in);
fclose($out);

你挑哪件工具?数据经由流流动、你想让 PHP 打理管道时,用过滤器;需要逐块验证、进度上报或损坏偏移量时,用分块循环。

数据库、配置文件与环境变量

Base64 是一个文本容器,正因如此,它会出现在你意想不到的地方。在数据库里,一个二进制大对象(文件、图标、序列化结构)可以 Base64 形式住在 TEXT 列里,躲过一切假定数据是文本的工具。存进去的值会比原始数据大约大 33%,建列时请把尺寸考虑进去。在配置文件和环境变量里,Base64 是夹带那些本会弄坏格式的值的手段:带分号的数据库 DSN、带引号的密码、带换行的值。

// .env 或配置文件,由运维人员写下:
//   DB_DSN_B64 = cGc6aG9zdD1kYjtwYXNzd29yZD1xdSJvdGU=
$dsn = base64_decode(getenv('DB_DSN_B64') ?: '', true);
if ($dsn === false) {
  exit('DB_DSN_B64 is not valid Base64.');
}
// $dsn 现在是:pg:host=db;password=qu"ote

同样的告诫在这里要讲两遍。第一,这是格式安全,不是保密:开发人员在读到配置文件的那一刻,就能一次调用解开这个值。永远不要把密钥存成 Base64 然后管它叫加密。第二,在启动时验证:一个损坏的或只粘贴了一半的环境变量值,在严格调用下会得到 false,一行检查就能把晦涩的运行时错误变成可以着手处理的启动信息。

从命令行来

不是所有解码都发生在 web 请求内部。CLI 脚本、cron 任务和一行式命令一直在解码 Base64,而命令行正是这个函数与 php://stdin 相遇的地方:

php -r 'fwrite(STDOUT, base64_decode(file_get_contents("php://stdin"), true));' < payload.b64 > restored.bin

Shell 本来就有自己的 Base64 工具(coreutils 的 base64 -d),应付快速活儿足够了;PHP 一行式命令则是为下一步就是 PHP 逻辑的场合准备的:写数据库、调 API、跑验证。有两个与 shell 相关的坑。解码的输出是原始字节,所以把它送到文件里,或送到一个懂字节的命令里,而不是送到一个会把它弄坏的终端里。还有,一行式命令里也要保持严格标志打开,因为终端里一次被截断的粘贴配得上一个 false,而不是三个垃圾字节。

带 PHP 口音的坑

快速参观一遍 PHP 特有的陷阱,都收集在这一处:

  • 宽松的默认值是大头。base64_decode('V@hpcy') 不声不响地返回三个垃圾字节,所以每个解码不可信输入的场合都需要严格标志和一个 false 检查。
  • 在宽松模式下,单个字符解码成空字符串,一串纯空格也一样。空结果几乎证明不了什么;只有 false 才表示失败,而它只在严格模式下出现。
  • 查询字符串里的 + 在 PHP 看到它之前就已经是空格了。如果客户端不做百分号编码就发送 ?token=abc+def,PHP 递给你的是 abc def(这是表单编码行为,parse_str()urldecode() 共享),再多解码魔法也变不回那个加号。URL 安全的 Base64(根本没有加号)就是 URL 里令牌的正解。
  • 缺失的填充会被默默地替你补上。七个字符解码得跟八个一样;这很方便,但也意味着被截掉一两个填充的载荷照样能解码不出声,所以一次干净的解码永远无法完全证明载荷是完整到达的(Go 的 raw 编码器和 Java 同样宽容)。
  • mbstring.func_overload 的幽灵。那个弃用已久、曾把 strlen() 们改写成按字符计数的设置(在 PHP 8.0 中移除),过去会在 UTF-8 字符串上弄坏 Base64 的字节计算。你接手的遗留代码可能还带着为它写的注释和绕行方案。删掉它们。
  • 解码后的字节不是一个 UTF-8 字符串。对解码出来的二进制跑 preg_match()(带 /u 标志)或 mb_substr(),是"malformed input"(格式错误输入)报错的即时来源。先嗅探,再决定。
  • 从 PHP 8.1 起,传入 null 已被弃用。如果一个变量可能为 null,就在调用前把它合并为 ''
  • $_GET 们是按表单规则解码的,不是按 URL 规则。如果一个值是以百分号编码的形式到达的,rawurldecode() 是更安全的逆操作,因为它不碰 +

base64_decode 的简史

Base64 本身比现代 web 的大部分历史都老(管辖它的标准 RFC 4648 出自 2006 年,它成文化了 1996 年的 MIME 编码,而后者又源自 1990 年代初的 PEM 护甲)。PHP 这边则有一份属于自己的小小变更日志。

PHP 4 把 base64_decode() 作为核心函数发布,没有选项,也没有严格模式;宽松脾气是唯一脾气,你没有办法要求解码器抱怨一声。2006 年 11 月,PHP 5.2.0 加上了 $strict 标志,那条变更日志值得读一读:它是为了强制符合 RFC 3548,也就是今天 RFC 4648 的前身。那一个标志,后来被证明是这个函数一生中最有用的新增。

然后是 debug 之年。PHP 5.3 在两个点版本里修复了一串严格模式 bug:bug #52327(严格模式下开头的填充处理不当,5.3.4 修复)和 bug #55273(严格模式下拒绝填充后的空白,5.3.9 修复)。(2016 年的一处整数溢出修复也记在这个函数的名下:bug #72836,官方标题是"base64_decode 中的整数溢出导致堆损坏",5.6.25 修复,但该 bug 报告自己的复现代码和被修补的函数显示,真正的溢出发生在 base64_encode() 的长度计算里,而不是解码器中;那个标题是从原始报告继承来的名不副实。)每一处修复都收紧了上面表格里你看到的行为。PHP 8.0 给两个 Base64 函数加上了原生参数和返回类型,也就是你在本文开头看到的签名;同一发布线还移除了 mbstring.func_overload,那个悄悄弄坏字节计算多年的设置。PHP 8.1 弃用了向它们传 null。从那以后,接口面被冻结:一个参数,一个标志,一个返回类型,再无变化。

几个极客乐趣

既然这是一篇长文参考,这里有一些纯属有趣的 PHP 专属事实:

  • 空值恒等。base64_encode('')base64_decode('') 都是 ''。两个函数在两个方向上都把"空"当作一等值,全程没有 false 的事。
  • 一个奇怪的地址。在 PHP 手册里,两个 Base64 函数都住在"其他基础扩展"这本书的"URLs"章节中。并没有专门的"encoding"章节;你要在那里找到它们 - 就在那个章节列表的顶部,排在 parse_url() 们前面。
  • 解码器是一个同态。一条经典的 php.net 用户注释指出,这个函数是"按 4 分段"与"按 3 分段"字符串之间的同态 - 这正是用正式语言说:任何按四的倍数进行的切分都是合法切分。这就是分块解码那一节之所以成立的原因,也是 1 MB 文件可以按 50 KB 切片解码而零损失的原因。
  • 一个参数,一个标志。二十多年里,base64_decode() 恰好增加了一个参数($strict),而 base64_encode() 一个也没加。
  • 它有兄长。同一个核心扩展还带着 convert_uuencode()convert_uudecode()(在手册里列于 String Functions 之下),那是拨号上网时代的遗物,当时 uuencode 是二进制的传输首选。你几乎用不上它们,但如果哪天一个古老的 .uu 文件落进你的收件箱,PHP 能打开它。
  • 严格模式为邮件留着一扇开着的门。四个空白字符(空格、制表符、回车和换行)是故意被放进严格模式的,所以 MIME 包装的附件不需要任何预处理。其余一切,包括 NUL 字节,都是 false

另一个方向

以上就是解码这一侧,而大部分痛苦就住在这里,因为解码正是你遇见别人数据的地方:他们的填充选择、他们的换行、他们的字符集、他们的令牌。另一个方向 - 用 base64_encode() 把字节变成 Base64 字符串 - 是个更温顺的动物:它从不失败,没有严格模式,而它自己的那套陷阱(双重编码、包装不匹配、尺寸账单)则有自己的一份指南。PHP 中的 Base64 编码(本页有链接)以同样的深度覆盖编码器。

最后更新: 2026-09-08

相关文章: PHP 中的 Base64 编码:完整指南