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

PHP 中的 Base64 编码:完整指南

你手上有一份必须活着穿过某个不欢迎它的通道的数据。一个需要住进 JSON 字段里的二进制大对象。一张必须活在 HTML 标签里的图像。一个该放进配置文件里的证书。一个将在 URL、请求头和 cookie 之间旅行的令牌。这就是 Base64 编码的日常生活:它把原始数据每三个字节重写成四个字符,取自一个 64 字母的字符表,末尾由一两个 = 号收尾,于是结果成了任何载体都搬得动的纯文本。本站首页已经把格式讲得完整无缺,所以这篇文章聚焦于 PHP 给了你什么、它替你默默决定了什么,以及陷阱都藏在哪里。

在 PHP 这一侧,故事从好消息开始:base64_encode() 从 PHP 4 起就住在核心里,它只收一个参数,永远返回字符串,而且不可能失败。没有严格模式,没有错误路径,也没有任何配置。编码是确定性的:同样的字节永远产生同样的字母。你作为开发者的工作不是让函数工作起来,而是让周围的世界配合:为目的地选对字母表,加上对的换行,转换对的字符集,并且睁着眼睛扛下那 33%的尺寸账单。(Base64 通常会把数据撑大约三分之一,每三个输入字节变成四个字符;把这点记在脑子里,因为它会反反复复地回来。)

读完这篇文章,你将知道如何产出一个 PHP 开发者真正会遇到的每一种 Base64 风味:单行输出、MIME 包装的邮件、PEM 包装的密钥、URL 安全的令牌,还有 data URI,外加当数据大到装不进内存时的流式技巧。

一个函数,零选项

整个 API 就是下面这些,正是现代 PHP 报告的样子:

base64_encode(string $string): string

再读一遍。一个参数,一个返回值,没有标志。手册把它描述为 MIME base64,"设计目的是让二进制数据能活着穿过那些并非 8 位无损的传输层,例如邮件正文"。注意这个措辞没有承诺什么:没有换行,没有包装,也不关心输出将住在哪。函数吐出的是一长行,目的地想要什么样的包装,是你用第二次调用自己去做的事。从 PHP 8.0 起,签名上挂了原生类型;从 PHP 8.1 起,传入 null 会触发弃用通知,所以先把任何可空的值合并为 ''

输出尺寸遵循一个固定模式,调用之前就能预测:

输入字节数 输出字符数 填充
0 0
1 4 两个 =
2 4 一个 =
3 4
3,000,000 4,000,000
100,000 133,336 两个 =

这个模式是:每满一组三个字节,产生四个字符,外加最后一个不完整的分组,用一两个 = 号补齐。有一个值得知道的推论:一个字节和三个字节都产生四个字符,所以编码后的长度藏着确切的输入尺寸。你可以估算(除以四,乘以三,再减去填充),但读不出精确值。

换行该放在哪里

既然 base64_encode() 从不自带换行,包装的决定就是目的地的问题。实际上有三种答案。

不加换行。函数的原始输出,恰好一行。URL、JSON 载荷、请求头、数据库值,以及一切换行会变成 bug 的场合,要的就是它。大多数人说的"给我 Base64 就行了",指的也是这个。

MIME 包装:76 个字符加 CRLF。来自 RFC 2045 第 6.8 节的邮件约定:编码后的行不得超过 76 个字符,解码器必须忽略换行。经典的搭档调用是 chunk_split(),手册的"See Also"列表正是为此把它和 base64_encode() 配成一对:

$binary = file_get_contents('/var/www/uploads/report.pdf');
$wrapped = chunk_split(base64_encode($binary), 76, "\r\n");

PEM 包装:64 个字符加 LF。密钥和证书用的是更早的 Privacy-Enhanced Mail 约定(RFC 1421):更短的 64 字符行。同一个工具,不同的数字:

$der = file_get_contents('/etc/ssl/raw-key.der');
$armor = "-----BEGIN PRIVATE KEY-----\n"
  . chunk_split(base64_encode($der), 64, "\n")
  . "-----END PRIVATE KEY-----\n";

chunk_split() 有一个坑,两种包装风味都中招:即使输入长度恰好是行长的整数倍,函数也会在结果末尾加上分隔符。如果下游消费者被一个尾部的空行绊倒,原因就是这个;对分隔符来一次 rtrim() 就能解决。再留意一个将来会救你一命的不对称:解码器完全忽略换行,所以 MIME 包装的载荷和未包装的载荷解码出的是同样的字节。包装是对基于行的工具和人类的一点体贴,而不是语义上的差别。

让输出 URL 安全

标准字母表里有 +/,这两个字符在文本文件之外都是麻烦。表单编码的查询字符串里的 + 在你的应用看到它之前就变成了空格,而 / 在 URL 里是路径分隔符。文件名和令牌各有各的抱怨。RFC 4648 第 5 节用 URL 和文件名安全字母表解决了这个问题:+ 变成 -/ 变成 _,末尾的 = 填充通常直接丢掉。RFC 坚持认为这"不应被视为与 base64 编码相同",所以请把它当作一种独立的格式,通常被称为 base64url

生成它只需要两个字符串操作:

function base64url_encode(string $data): string
{
  return rtrim(strtr(base64_encode($data), '+/', '-_'), '=');
}
var_dump(base64url_encode("hi>?there\x00bin")); // string(18) "aGk-P3RoZXJlAGJpbg"

什么时候用它:JSON Web Token 的各部分、OAuth 的 state 和 nonce 参数、你放进 URL 路径里的 API ID,以及一切会被复制进地址栏或文件名的东西。什么时候别用:邮件正文、PEM 护甲,以及对面坐着标准字母表消费者的一切场合,因为 -_ 不在它们的词汇表里。还有,别悄悄混用两套字母表:一个 URL 安全编码的令牌必须 URL 安全解码,到处都这样,永远都这样。这就是 base64url 全部的互操作规则。

Unicode 与你真正想要的字节

PHP 字符串是字节序列,base64_encode() 会照单全收地编码交给它的任何字节,从不问它们是什么意思。直到有一天你的"文本"其实并不是你以为的那种编码,它都是优点。经典翻车现场:一个字符串在你的编辑器里看起来像 UTF-8,实际上是从某个遗留来源以 Windows-1252 形式到达的。把这些字节原样编码,接收方解码时又假设是 UTF-8,于是得到一堆乱码,而不是你那些带重音字母的文本。

解决办法是编码之前先归一化,工具是 mbstring 扩展(它随 PHP 源码一起发布,但默认不启用):

$fromLegacy = "caf\xE9 au lait"; // Windows-1252 字节:é 是 0xE9
$utf8 = mb_convert_encoding($fromLegacy, 'UTF-8', 'Windows-1252');
$encoded = base64_encode($utf8);
var_dump($utf8); // string(13) "café au lait":é 现在是两个 UTF-8 字节

如果来源本来就是 UTF-8,可以跳过转换;一个便宜的健全性检查是 mb_check_encoding($utf8, 'UTF-8')。一句话建议:永远不要试图通过把已经编码好的 Base64 字符串当文本再编码一次来"修复"它。那就是下面坑点章节里的双重编码陷阱,也是 PHP 代码库里最常见的那个 Base64 bug。

文件、二进制大对象与 .b64 约定

最直接的编码活儿:文件变成文本。PHP 字符串就是字节,所以不存在什么需要操心的"二进制模式";file_get_contents() 把精确的字节交给你,base64_encode() 把精确的文本交给你:

$binary = file_get_contents('/var/www/uploads/photo.png');
$encoded = base64_encode($binary);
file_put_contents('/var/www/uploads/photo.png.b64', $encoded);
var_dump(strlen($binary), strlen($encoded)); // 33% 的账单,次次如此

两个习惯让这件事保持安全。第一,知道自己编码的是什么。finfo 类(fileinfo 扩展,随标准 PHP 构建一起捆绑)能从字节告诉你真实的类型,而不是从文件名:

$mime = (new finfo(FILEINFO_MIME_TYPE))->file('/var/www/uploads/photo.png');
var_dump($mime); // string(9) "image/png"

第二,规划存储时记住尺寸账单:一张 500 KB 的图像会变成 670 KB 的文本文件,一个 1 GB 的视频会变成 1.33 GB 的文本文件。下面大数据那一节存在的意义就在这。

Data URI:把图像放进页面里

data URI 把载荷直接嵌进 URL 里,所以不需要第二个请求去取它。RFC 2397 定义了它的形状:data:,可选的媒体类型,可选的 ;base64 标志,一个逗号,然后是数据。对图像这类二进制媒体,标志是在场的,所以载荷正是 base64_encode() 产出的东西:

function data_uri_for(string $path): string
{
  $mime = (new finfo(FILEINFO_MIME_TYPE))->file($path);
  $payload = base64_encode(file_get_contents($path));
  return 'data:' . $mime . ';base64,' . $payload;
}
$uri = data_uri_for('/var/www/uploads/avatar.png');
echo '<img src="' . $uri . '" alt="avatar" />' . "\n";

这里为什么用 Base64?因为 URI 无法安全地包含原始字节或逗号,而 Base64 字母表根本不需要任何转义。不过代价是实实在在的。编码后的载荷比文件大约大 33%,HTML 文档本身因此变大。浏览器不会像缓存文件 URL 那样缓存 data URI,所以每一次页面浏览都会重新下载这些字节。而且 RFC 自己都说 data URI 只对短值有用;老的 HTML 解析器对属性长度有硬限制,现代浏览器虽然宽容得多,但也并不喜欢标签里塞着几兆字节。头像、图标和小的内联图形,用它们;其他一切,用真正的文件。

JWT 与 API 令牌

JSON Web Token 是现代 API 里 Base64 的旗舰消费者,它用的是上面那一节里的 URL 安全、无填充方言。按 RFC 7519,紧凑格式的 JWT 是三个用点分隔的 base64url 部分:头部、载荷、签名。头部和载荷是纯 JSON;签名是原始字节。亲手造一个,是看清每个活动部件的愉快方式:

function base64url_encode(string $data): string
{
  return rtrim(strtr(base64_encode($data), '+/', '-_'), '=');
}
$header = base64url_encode(json_encode(['alg' => 'HS256', 'typ' => 'JWT']));
$payload = base64url_encode(json_encode([
  'sub' => '1234567890',
  'name' => 'John Doe',
  'iat' => 1516239022,
]));
$signingInput = $header . '.' . $payload;
$signature = base64url_encode(hash_hmac('sha256', $signingInput, $secret, true));
$token = $signingInput . '.' . $signature;

有两点值得注意。签名是原始 HMAC 字节的 base64url 编码,这就是 hash_hmac()true 调用、要原始输出的原因。而且头部和载荷任何人可读,这是设计使然:JWT 是一张签过名的票据,不是秘密。生产环境里,签名和验证都不要手写。社区的包是 firebase/php-jwt(v7,要求 PHP 8.0 或更高),用 Composer 安装:

composer require firebase/php-jwt
use Firebase\JWT\JWT;
$secret = 'correct-horse-battery-staple-long-enough-secret';
$token = JWT::encode([
  'sub' => '1234567890',
  'name' => 'John Doe',
  'exp' => time() + 3600,
], $secret, 'HS256');
var_dump(substr_count($token, '.')); // int(2):头部、载荷、签名

关于该库 v7 系列的版本说明:HMAC 算法强制最小密钥长度,所以短于 32 字节的 HS256 密钥在任何编码发生之前就会被拒绝。密钥长本来就是常态;这只是让库拒绝在这件事上随便了事。

这个库替你处理 base64url 转换、签名和过期检查,并且抛出带类型的异常,而不是返回半信半疑的数据。用它签发令牌时,你从不直接碰 base64_encode(),这正是应有的样子。

HTTP:Basic 认证与 WebSocket 握手

两个构造请求头的活儿:PHP 负责 Base64,协议负责剩下的部分。

HTTP Basic 认证(RFC 7617):客户端发送 Authorization: Basic,再加上 username:password 的 Base64。构造它就是字符串拼接一次的事:

function basic_authorization_header(string $username, string $password): string
{
  return 'Basic ' . base64_encode($username . ':' . $password);
}
$headers[] = 'Authorization: ' . basic_authorization_header('alice', 'secret123');

把它大声说一遍,因为 RFC 逼你这么说:这是编码,不是保护。任何拿着抓包工具的人都能一键恢复出前后两半,所以 Basic 认证只配待在 HTTPS 连接上。

WebSocket 握手(RFC 6455):服务器通过回显一个变换后的密钥来证明自己听到了客户端。它把客户端的 Sec-WebSocket-Key 和一个固定的魔法 GUID 拼在一起,对结果取 SHA-1,再对摘要做 base64 编码。这是标准 Base64,连填充都在,因为它住在请求头里,而不是 URL 里:

function websocket_accept_key(string $clientKey): string
{
  return base64_encode(hash('sha1', $clientKey . '258EAFA5-E914-47DA-95CA-C5AB0DC85B11', true));
}
$accept = websocket_accept_key('dGhlIHNhbXBsZSBub25jZQ==');
var_dump($accept); // string(28) "s3pPLMBiTxaQ9kYGzzhZRbK+xOo="

这个例子就来自 RFC 本身,所以它顺手成为一个自测:如果你的实现产出同样这 28 个字符,WebSocket 这一层就在正确地说话。

电子邮件:最初的用例

这篇文章里其他一切,都是一个事实的后代:SMTP 被设计成只搬 7 位 ASCII,而人们又想发送二进制。MIME 标准的答案写在 RFC 2045 第 6.8 节:把 Base64 作为一种 Content-Transfer-Encoding,外加你已经见过的两条家规:行最多 76 个字符,解码器忽略字母表之外的每个字符。所以一个 PDF 附件是这样旅行的:

$pdf = file_get_contents('/var/www/uploads/report.pdf');
$attachment = chunk_split(base64_encode($pdf), 76, "\r\n");
// 邮件库此时把 $attachment 放进 MIME 部分,
// 并带上 Content-Transfer-Encoding: base64

实际的数字:编码本身花 33%,每 76 个字符一个 CRLF 再多花一点,所以一个 100 KB 的附件会以大约 137 KB 的文本发出去。从 PHP 写邮件时,库(PHPMailer 和它的一众近亲)会替你完成包装,你交给它们原始二进制就好。如果你哪天在一个原始 .eml 文件里看到一面 76 字符宽的字母墙,现在你知道产生它的确切算法了。

给密钥和证书穿上 PEM 护甲

密钥和证书需要的不只是一面字母墙,它们还需要标签。PEM 护甲是一个 BEGIN 行、一块按 64 个字符包装的 Base64 块,再加一个 END 行 - 这是从 Privacy-Enhanced Mail(RFC 1421)继承下来、被 OpenSSL 延续至今的约定。PHP 的 openssl 扩展直接产出并消费这种形状:

$res = openssl_pkey_new([
  'private_key_bits' => 2048,
  'private_key_type' => OPENSSL_KEYTYPE_RSA,
]);
openssl_pkey_export($res, $pem);
// $pem 已经穿着护甲:BEGIN 标签、64 字符行、END 标签

有意思的场合是护甲必须手工重建的时候,比如你从一个 API 拿到裸的 DER 字节,而某个工具只认 PEM 文件。约定是:每行 64 个字符,LF 换行,再加一个标明内容的标签:

$rearmored = "-----BEGIN CERTIFICATE-----\n"
  . chunk_split(base64_encode($der), 64, "\n")
  . "-----END CERTIFICATE-----\n";
var_dump(openssl_x509_parse($rearmored) !== false); // bool(true):护甲是有效的

标签搞错,文件就是垃圾,无论 Base64 有多完美。行长搞错,多数工具照样读得出来,因为解码器忽略换行,但 diff 工具和人类会遭殃。六十四,就是这个数字。

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

Base64 是一个文本容器,这使它成了夹带工具 - 专夹那些本会弄坏容器的值。一个满是分号和引号的数据库 DSN、一个放在 .env 文件里的 JWT、一个住在 TEXT 列里的二进制大对象:它们统统变成一个又长又安全的字符串。

环境变量这种风味是个两步仪式。第一步,在构建配置的那台机器上,编码一次:

echo 'DB_DSN_B64=' . base64_encode('pg:host=db;password=qu"ote') . PHP_EOL;

第二步,在应用每次启动时解码并在启动时验证,这样一只粘贴了一半的配置会大声失败,而不是晦涩地失败:

$dsn = base64_decode(getenv('DB_DSN_B64') ?: '', true);
if ($dsn === false) {
  exit('DB_DSN_B64 is not valid Base64.');
}

对数据库来说,同一个思路把二进制存进文本列。尺寸账单同样生效:存进去的值比大对象大约大 33%,所以 1 MB 的文件在列里占大约 1.33 MB,选列类型时请把这点考虑进去。还有和处处相同的警告:这是格式安全,不是保密。任何能读到配置或查询这列的人,一次调用就能还原。如果值敏感,就加密它;Base64 只是让它可携带。

大数据与稳定的内存

编码是你花钱的方向:输出比输入大三分之一,所以一个 2 GB 的二进制要在内存里要 2.66 GB 的编码字符串。在长驻的 web 进程或内存受限的主机上,这就是应该流式处理而不是一次性吞下的理由,PHP 给了你两条路。

第一条路是 convert.base64-encode 流过滤器,函数的流式孪生兄弟。它支持以关联数组传参数:line-length 管换行宽度,line-break-chars 管分隔符,这能在不持有整个字符串的情况下复现 chunk_split() 的效果:

$in = fopen('/var/www/uploads/big.bin', 'rb');
$out = fopen('/var/www/uploads/big.b64', 'wb');
stream_filter_append($out, 'convert.base64-encode', STREAM_FILTER_WRITE, [
  'line-length' => 64,
  'line-break-chars' => "\n",
]);
stream_copy_to_stream($in, $out);
fclose($in);
fclose($out);

第二条路是经典的 57 字节技巧,它也是 PHP 传说里的一小块。一行 76 字符的 MIME 行恰好装下 57 字节的原始数据,所以如果你按 57 的倍数分块读取输入文件,每个分块都能独立编码,块与块之间没有任何零头需要携带。按 8151 字节分块读取(57 乘 143:产出 143 整行 76 字符的输出,接近 PHP 传统的 8192 字节 I/O 缓冲),文件以完美的 MIME 形态流出去的同时,内存保持平坦:

$in = fopen('/var/www/uploads/big.bin', 'rb');
$out = fopen('/var/www/uploads/big.b64', 'wb');
while (!feof($in)) {
  $plain = fread($in, 57 * 143);
  $encoded = chunk_split(base64_encode($plain), 76, "\r\n");
  fwrite($out, $encoded);
}
fclose($in);
fclose($out);

选哪一个?想让 PHP 掌管管道、你又不关心确切的分块边界时,用过滤器;想要确定性的 MIME 输出、进度钩子,或给缓冲大小一个硬上限时,用 57 字节循环。无论哪条路,内存占用都是一个分块,而不是一个文件。

带 PHP 口音的坑

在真实 PHP 代码库里会冒出来的陷阱,收集在这一处:

  • 双重编码。经典款:一个已经是 Base64 的值(来自环境变量、数据库或前一个脚本),因为没人检查,又被送进 base64_encode() 再编码一次。结果解码一次之后,得到的还是……更多 Base64。解药是在边界处做往返检查,或者让一个公认的函数垄断整个代码库里的编码。
  • URL 里的 +。标准输出里含有 +/。在表单编码的查询字符串里,加号在你的代码看到它之前就成了空格;在路径里它是分隔符。对任何与 URL 绑定的东西,要么产出 base64url,要么用 rawurlencode() 把整个值做百分号编码。
  • 尾部的分隔符。chunk_split() 即使输入恰好是行长的整数倍,也会在结果末尾放上分隔符。尾部的空行通常无害(解码器会忽略它),但会绊倒朴素的行数统计器和 diff 工具。消费者挑剔的话,用 rtrim() 去掉分隔符。
  • 包装不匹配。用 76 字符的 MIME 行写入,消费者却期待 64 字符的 PEM 行(或反过来),这不是解码问题,因为解码器忽略换行,但它是行工具和人类阅读的问题。挑目的地期待的那个约定,然后坚持住。
  • 尾部换行也是数据。base64_encode() 编码每一个字节,包括文本文件末尾的换行。当两个系统对看起来相同的文本产出"不同"的 Base64 时,惯犯就是尾部那个 \n
  • Base64 不是加密。在密码进数据库之前给它编码,不是在保护它,是在给它排版。对有查询权限的人来说,"加密"过的列离明文只差一次函数调用。真正的密钥要加密或哈希;Base64 只是一套运输行头。
  • 内存要大三分之一。在 32 位 PHP 构建或内存限制紧张的主机上,编码一个大二进制可能直接失败。先按上面那样流式处理,再考虑调 memory_limit
  • 永远不会添加换行。函数描述里的"MIME base64"不等于"MIME 包装的输出"。如果你的输出需要 76 字符的行,用 chunk_split() 或过滤器自己加。

base64_encode 的简史

PHP 故事里编码这一侧几乎无聊得令人清爽,而且是最好意义上的无聊。base64_encode() 在 PHP 4 里作为核心函数登场,一个参数,没有任何选项,从那以后一个也没加过。从来不需要严格模式(数据是你自己产出的,没什么可严格的),从来没加过填充选项,包装的活儿从第一天起就委托给了 chunk_split(),这也是为什么这两个函数至今还并排坐在手册的"See Also"列表里。

手册上那句 33%的数字,久到没人记得是从什么时候开始就在那了:"Base64-encoded data takes about 33% more space than the original data"(Base64 编码后的数据比原始数据多占大约 33% 的空间)。这句话今天还在那里,而这个数字之所以出现在本文里,原因也正在于此。流过滤器 convert.base64-encode 是后来才加入的,它也有自己要长大的 bug:2015 年,PHP 修复了一个缺陷(bug #68532),该过滤器在内存流的读取模式下可能漏掉最后一个填充字符 - 而这恰恰是函数式路径从不遭遇到的一类静默损坏。PHP 8.0 加上了原生的 string 参数和返回类型,变更日志到此为止。一个函数,一个参数,二十年,零选项:一座把接口面一次做对的纪念碑。

有趣的 PHP 事实

一篇没有怪癖的参考文章是不完整的:

  • 空值恒等。base64_encode('')''。没有填充,没有输出,没有意外:空进去,空出来。
  • 一个奇怪的地址。PHP 手册把 base64_encode() 归在"其他基础扩展"这本书的"URLs"章节下。没有"encoding"章节;你要在"URLs"里找到它,和 parse_url() 做伴。
  • 字母表从未挪窝。同样 64 个字符从 PHP 4 起就在 PHP 编码器里输出。一个 2001 年在 Windows 机器上由 PHP 4 脚本产出的 Base64 字符串,今天在 Linux 上的 PHP 8.4 解码出来分毫不差。这是有 25 年业绩记录的互操作性。
  • 一个字节和三个字节看起来长度一模一样。都产生四个字符,只有填充能把它们区分开。这就是本文那张尺寸表存在的原因。
  • 五十七是个魔法数字。一行 76 字符的 MIME 行恰好装下 57 字节的原始数据,正是这个巧合让大数据章节里的流式分块循环成为可能,而且读取之间不需要携带任何状态。
  • 它有一个拨号时代的兄长。base64_encode() 的"See Also"里甚至列着 convert_uuencode(),那是 uuencode 的 PHP 包装,而 uuencode 是 MIME 把 Base64 标准化之前为邮件编码二进制的格式。它住在 String Functions 章节,但它是这个函数最初用途的化石记录。
  • 老浏览器对填充很挑剔。一条 2004 年的 php.net 注释报告说,Internet Explorer 拒绝包含 = 的 cookie 名,这就是为什么老手代码有时会剥掉存在 cookie 里的 Base64 的尾部填充。现代环境不需要这个技巧,但它能解释你可能接手到的那些奇怪的 rtrim($x, '=') 调用。

另一面

以上就是编码这一侧,也是两者中更容易的那侧:函数不会失败,输出是确定性的,格式从首到尾都由你控制。更难的方向是接收别人的 Base64 的那一侧:他们的填充选择、他们的换行、他们的 URL 安全方言、他们损坏的粘贴。解码器需要严格模式、一条验证流水线,以及对一切的健康怀疑,正是在这一侧。PHP 中的 Base64 解码(本页有链接)以同样的深度覆盖解码侧。

最后更新: 2026-09-08

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