Base64 形式を扱う必要がありますか?それならこのサイトが最適です!データをエンコードまたはデコードするために便利なオンラインツールをご利用ください。

PHP での Base64 デコード:完全ガイド

サポートチケット、API ログ、設定ファイル、あるいは URL の真ん中で遭遇します。アルファベットと数字が長く連なり、ときどき + や / が挟まり、末尾に = がひとつふたつ付いているような文字列です。一瞬で見分けがつきます。Base64 はバイナリからテキストへの変換形式です。生のデータの 3 バイトを 64 文字のアルファベットから選んだ 4 文字に書き換えて、バイト数が 3 の倍数でないときは、= がいくつかで末尾を仕上げます。デコードはこの取引で縮む方向です。4 文字が入っていって、3 バイトが出てくる。このサイトのホームページがこの形式を段階を追って説明しているので、この記事は本来あるべき場所にエネルギーを使います。PHP 側での仕事の仕方についてです。

まず見出しから。PHP は PHP 4 の頃からコアに Base64 ディコーダーを積んでいます。base64_decode() は拡張機能も、Composer パッケージも、設定も必要なく、PHP が動く場所ならどこでも動きます。あまり良いとは言えないニュース:デフォルトのモードは、壊れた入力をこっそり飲み込み、一言も言わずにゴミデータを差し出します。良いニュースはさらに良い方向へ:フラグひとつ($strict)で、この関数は立派な門番になります。どのモードを選べばよいのか、入力が本物かどうかをどう証明するのか、バイトを意味に戻す方法を覚えれば、Base64 はもはや謎のバグの出どころではなくなり、自動化できる日常業務になります。

サイズについてひとこと:デコードはデータを約 4 分の 1 に縮小します(4 文字が入力されるたびに 3 バイトが出てくるため)なので、出力は常に入力より少ないメモリで済みます。デコードでメモリが爆発する心配はありません。さあ、ツールと出会うことにしましょう。

仕事をこなす関数

完全なシグネチャは、最新の PHP が報告するそのままの形がこちらです:

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

その行にある 3 つの単語が、すべての仕事をします。$string にはサイズ制限がありません。メガバイトひとつは 1 ミリ秒に満たない時間でデコードされるので、ファイル全体を一度の呼び出しでデコードすることをも妨げるものはありません。戻り型が契約全体を言い表します。デコードされたバイトの文字列、もしくは false。例外も、エラーコードも、第二のチャネルもありません。false はあなたが得られる唯一の信号なので、それを確認することは仕事の一部です。そしてマニュアルの一文は、暗記するに値します:返されたデータはバイナリである場合があります。結果に PNG や ZIP、ハッシュが入る瞬間から、それは緩い意味でも「テキスト文字列」ではなく、PHP はそれでも構わないと、文字列として扱ってもよいと快く許します。その柔軟性は超能力であり罠であり、下記の各セクションはそれを常に手なずけておきます。

バージョンタグを素早く振り返りましょう。引き継いだコードは、あれこれ勝手に決めつける癖があるからです。この関数は PHP 4 からコアにありました。$strict パラメータは 2006 年 11 月、PHP 5.2.0 で登場しました。PHP 8.0 から、シグネチャには本物のネイティブ型が付き(上で見られる string と bool、加えて string|false の戻り型)なので、IDE と静的解析ツールはようやくこの関数が失敗し得ることを知るようになりました。PHP 8.1 から、null を渡すと非推奨の通知が発行されます。「何もない」という意図なら、'' を明示的に書きましょう:

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

strict モードか、無言の整理か

$strict フラグは、性質がまったく異なる 2 つのモードを切り替えるスイッチです。オフ(デフォルト)なら、ディコーダーは気前のある健忘症です:Base64 アルファベットの外のすべての文字が黙って捨てられ、残りはデコードされ、誰にも知らせません。マニュアルは率直に言います:それ以外の場合、無効な文字は黙って破棄されると。オンなら、ディコーダーは門番です:認識できない最初の文字が現れた瞬間、ペイロード全体に false が突きつけられます。

これが被害レポートです。以下のすべての行は、PHP 8.x での base64_decode() の実際の挙動です:

入力 緩い(デフォルト) strict
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、5 文字 "foo"、最後の文字が捨てられる false
Z、単独の 1 文字 ""、空の文字列 false
=Zm9、先頭からパディング "fo" false
Zm9vYmFy==、完全なグループの後のパッド "foobar" false
Zm9vYmFy==A、パッドの後のデータ "foobar" false
Zm9vYmF、7 文字、パッドなし "fooba" "fooba"

3 行は二度目の注意を払うに値します。V@hpcy の行は、入力が信頼できない場所ではなぜ緩いモードが危険かを示します:孤立した @ はデコードを止めません。ただ消えるだけで、出てくる 3 バイトには何の意味もありません。単独の Z の行は、空の結果はほとんど何も証明しないことを示します:1 文字のペイロードは失敗することなく「デコードされて」空の文字列になります。Zm9vYmFy==A の行は、パッドの後に現れるデータをディコーダーが快く無視することを示し、切り詰められたり改ざんされたりしたペイロードが完全無欠に見え得る仕組みです。

strict モードはなお何を通しますか?空白文字はちょうど 4 つです:スペース、タブ、キャリッジリターン、改行。位置はどこでも、= のすぐ隣であっても。これは意図的なものです。MIME で折り返されたメールのペイロードは、エンコードされたストリームの中に CRLF の改行を持ち、strict モードは事前処理なしにそれをかいくぐって処理します(なぜそれがそうなっているかは、下記のメールのセクションで説明します)。アルファベット文字でないそれ以外のすべて、NUL バイトから垂直タブまで、false を突きつけます。

PHP 固有の特徴ではありませんが、知っておくべき本当の緩さがひとつあります:PHP は足りないパディングを黙って補完してくれます。7 文字のペイロード Zm9vYmF(パッド一切なし)は、パッド付きの兄弟 Zm9vYmF= とまったく同じく、どちらのモードでも "fooba" にデコードされます。RFC 4648 は一般的な場合でパディングを求めているので、パッドのない末尾を受け入れるのは意図的な緩和であり、PHP 固有のものでもありません:Go の RawStdEncoding と Java のディコーダーは同じパッドなしの受け入れをします。あなたの PHP 側と相手側のシステムが、端境のペイロードで食い違うなら、足りないパッドがまず見るべきところです。

標準は strict なモードに同意します。RFC 4648 の 3.3 節は、アルファベット外の文字を含むエンコードデータを、実装は拒否しなければならないと定めています。ただし、周囲の仕様が別を定めている場合は別です(MIME はまさにその「別を定める」古典的なケースです)。同じ節は理由も説明しています:アルファベット以外の文字は隠れチャネルとして悪用され得て、ディコーダーが捨ててしまう文字に情報を隠すことができるためです。実際にそれらがディコーダーのバグを誘発するために使われてきました。入力が外の世界から来るなら、strict モードはスタイルの選択ではありません。標準が求めているものです。

ペイロードが Base64 であることを証明する

静かに失敗し得るディコーダーには、その前に検証パイプラインが必要になります。3 つのレイヤーがあり、それぞれが他のレイヤーが取りこぼすものを拾います。

レイヤー 1 は正規表現による形チェックです:アルファベット文字のみ、そして末尾に最大 2 つのパッドまで。

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

正規表現は、他の何かが走る前に、明らかなゴミ(放浪のスペース、@ 記号、文字列の真ん中にあるパッド)をキャッチします。ただしこれだけでは検証器にはなりません:Zm9vYmFy= がパッド 1 つの 9 文字であり、strict モードでも拒否されることを、それでは見られません。まさにそれこそがレイヤー 2 が存在する理由です。strict なデコードは Base64 の意味論を理解する唯一のチェックなので、最終的な決め手はそちらになります。

レイヤー 3 は誰もが忘れがちなものです: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() は安心のための任意のものです:strict モードはすでに CRLF を許容しますが、取り除いておくことで、後でやる長さの計算がきれいに保たれます。なぜならきれいなペイロードの文字数は常に 4 の倍数だからです。(4 の倍数より 1 つ多い、5 や 9 のようなものは Base64 では不可能で、strict モードはそれを拒否します。) 注意:この関数は自ら決して例外を投げません。チェックはあなたが書くものです。

URL 安全な Base64

実世界ではもうひとつのアルファベットに出会います。そしてそれが咬むのです。標準 Base64 は + と / を使いますが、この 2 文字は URL では厄介です:クエリ文字列の + は、PHP がそれを見る前にスペースとして解釈されてしまいますし、/ はパスの区切り文字です。RFC 4648 の 5 節はこの修正を定めています:URL とファイル名で安全なアルファベットで、+ が - に、/ が _ になり、末尾の = パディングは通常、文字数を節約するために落とされます。RFC は、これは「base64 エンコードと同じとは見なすべきではない」と強く言い、最も耳にするのは base64url という名前です。JSON Web Token、OAuth の state パラメータ、API のセッション ID、動画サイトの URL はすべてこの方言の中で暮らしています。

デコーダー側は 2 段階です:アルファベットを元に戻して、それから足りないパディングを復元する。ここに、最終的にどこでも再利用するようになるヘルパーがあります:

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 安全なペイロードがたまたま - も _ も含まないなら、その特定のデータに対しては 2 つのアルファベットはバイト単位で同一で、どちらのディコーダーを使ったかは関係ありません。危険は、それらの文字が存在する場合にだけ現れます。なぜなら、アルファベットが異なるのはそこだけだからです。

テキスト、バイト、文字コード

Base64 はあなたのバイトが何を意味するのかを知りません。PHP のディコーダーは、その盲目をそのまま受け継ぎます。このコーデックは文字コードに盲目です:UTF-8 テキストでも、Windows-1252 テキストでも、JPEG でも、ハッシュでも、入っていったのと同じ 8 ビット値を返します。PHP 自身も同じ考え方です:文字列はバイトの配列にすぎません。結果を表示したり他のテキストと比較したりしたくなった瞬間、誰かが 2 つの質問に答えなければなりません:これはそもそもテキストなのか、そしてテキストならどの文字コードなのか?

実践的なテストには 2 つのバケットがあります。バイナリは、NUL と低い値の制御バイトで自分をほぼ必ず名乗ります。そして有効な UTF-8 ではないテキストが第二のバケットです。mbstring 拡張機能(デフォルトでは無効)は、strict な 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 は Web とデスクトップデータで最も一般的なレガシー文字コードで、それが素の ISO-8859-1 と異なる点は、バイト 0x93 がカーリークォート(二重引用符)なのか、見えない制御文字なのかを決めます:

// Windows-1252 の「café」:é は 1 バイト、0xE9
$legacy = base64_decode('Y2Fm6Q==', true);
$utf8 = mb_convert_encoding($legacy, 'UTF-8', 'Windows-1252');
var_dump($utf8); // string(5) "café":é は今や 2 バイトの UTF-8

有名な mb_detect_encoding() への警告:PHP マニュアル自身は、自動検出は「決して完全に信頼できるわけではない」と言い、キーなしで暗号文を解読するのにたとえています。Windows-1252 の「café」を与えると、Windows-1252 と言うかもしれません。PNG ヘッダーを与えると、今度は快くまた Windows-1252 と言うかもしれません。なぜなら ISO-8859 系の文字コードはすべての考えられるバイト値に対して定義されており、そのため何でも一致させられるからです。検出は最後の手段として扱い、宣言された文字コード(ヘッダー、設定行、データベースのコレーション)が存在するときは常にそれを信じ、残りはデフォルトで UTF-8 かバイナリにします。

ペイロードがファイルになったとき

最も一般的なファイル処理は、どこかのエクスポート処理がやったことと逆のものです:.b64 テキストファイルが届いて、元のファイルを取り戻す必要があります。strict なデコードと 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 アーカイブなのか、動画なのかを気にしません。サイズの計算はあなたに有利に働きます:デコードされた出力はエンコードされた入力の 4 分の 3 の長さなので、デコードはメモリを決して悪くしません。

良い習慣は、ラベルを信じる前に、バイトに自分を名乗らせることです。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"

この形式には 2 つの落とし穴があります。最初のは、足りない ;base64 フラグです:フラグのない合法な data URI はパーセントエンコードされたペイロードを持ち、それを base64_decode() に通すとゴミが出ます。二つ目は、主張されたメディアタイプです:それは送信者からのヒントであり、事実ではありません。ファイルのセクションからの finfo チェックが、あなたの事実です。そして RFC 自身の助言を覚えておいてください:data URI は短い値にしか役に立たない、と。マルチメガバイトの画像を URL に埋めるのは、パターンではなく悪臭です。

JWT:中のぞき込めるトークン

Web で最も有名な Base64 ペイロードは JSON Web Token で、形を知れば最も怖いものの一つではありません。RFC 7519 に従い、コンパクトな JWT はドットで区切られた 3 つの URL 安全な Base64 部分から成ります:ヘッダー、ペイロード、署名の順で、それぞれはパディングなし、改行なしでエンコードされます(RFC 7515 は、余計な文字が入り込んではならないと明記しています)。ヘッダーとペイロードはプレーンな JSON なので、誰でも読め、だからこそ誰もがトークンを触る前に次の段落を理解すべきなのです。

最初の 2 つの部分を読むのは、上記のヘルパーを使った 5 行の仕事にすぎず、トークンを謎めかせない良い方法です:

$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) }

ここが重要なのは、第三の部分です:第三の部分は署名であり、あなたがたった今デコードした 2 つの部分は秘密でもなければ、認証されたものでもない。パケットキャプチャを持つ誰でもそれらを読み、テキストエディタを持つ誰でもそれを書き換えることができる。署名を検証する前にペイロードを信頼するのは、古典的な JWT バグです。本番では、そのチェックを手作業で書かないでください。コミュニティの答えは 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) {
  // 不正なトークン、署名の失敗、あるいは期限切れのクレーム
}

バージョンに関する注意 1 つ:このライブラリの v7 は、HMAC アルゴリズムに最小キー長を強制します。そのため 32 バイト未満の HS256 シークレットは、署名が検証される前に DomainException で拒否されます。シークレットは長めにしてください。ライブラリは、それを忘れさせてくれません。

その API の順序に注意してください:JWT::decode() は、署名の失敗、期限切れのトークン、アルゴリズムの欠落に対して、ゴミを返すのではなく例外を投げます。そのため、手元に戻ってくるペイロードは信頼できるものです。上記の手作業版は理解のために、そしてあなたに向けられたはずのないトークンの中のぞき込むためにある。ライブラリは信頼するためにあります。

HTTP Basic 認証:最も古いヘッダー

Web で最も古い認証ヘッダーは、なお Base64 に乗っています。RFC 7617 に従い、HTTP Basic リクエストは Authorization: Basic を送った後、username:password の Base64 エンコードを送ります。RFC は、これは保護ではなくエンコードであると明言しています:パケットキャプチャを持つ誰でも、一連打で両方をデコードできます。デコード側でのあなたの仕事は、ヘッダーを解析し、strict にデコードし、タイミングに安全な関数で比較することです。

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])
) {
  // 認証済み
}

2 つの詳細が、これを守ってくれます。explode() の上限の 2 は重要です。パスワードは合法にコロンを含み得るためです。また、比較は hash_equals() で、決して == ではありません。そうしなければ、攻撃者はタイミングを測ってあなたのユーザーリストを突っ走れます。そしてこれは HTTPS でのみ提供してください。素の接続では、Base64 レイヤーは装飾にすぎません。

メール:すべてが始まった場所

Base64 は特定の問題のために生まれました:メール伝送は 7 ビット ASCII のみを運んでいましたが、人々はバイナリを送りたかったのです。MIME 標準(RFC 2045、6.8 節)は Base64 をバイナリの転送エンコードのひとつに定め、2 つのハウスルールを追加しました。第一に、エンコードされた行は 76 文字を超えてはなりません。第二に、デコードソフトはアルファベット外のすべての文字、改行を含め、無視しなければなりません。その第二のルールこそが、PHP のディコーダーがどちらのモードでも、あなたの事前処理なしに CRLF で折り返されたペイロードをかじり抜いて処理する理由です。(これはまた、上記の strict モードの表で見えた \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):すべてのバイトが往復を完了した

実践的な注意が 2 つ。第一に、折り返しは重さを増します:76 文字ごとに CRLF があると、100 KB の添付ファイルは約 137 KB のテキストとして届きます(いつもの 4 分の 3 倍の係数に、改行のオーバーヘッドを加えたもの)。第二に、ヘッダーや複数の部分、quoted-printable の兄弟を持つ現実世界のメールについては、任意の mailparse 拡張機能が RFC 822 メッセージ全体を部分ごとに解剖します。既知の 1 つの添付ファイルなら、strict なデコードだけで十分です。

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 に縫い合わせることなく回避できます。

ストリームと大きなペイロード

デコードはあなたを助けてくれる方向です:出力は入力の 4 分の 3 のサイズなので、Base64 によるメモリ圧力はめったにありません。それでも、数百メガバイトの .b64 ファイルがディスクに降り立つと、フットプリントを平らに保つためのツールが 2 つあります。

最初はチャンク化デコードです。クリーンな入力を、長さが 4 文字の倍数となるパーツに分割し、各パーツを strict にデコードして連結します。すべてのチャンクは独立した有効なペイロードなので、境界で失われるものはなく、壊れたファイルは報告できるオフセットとともに即座に失敗します。

$clean = str_replace(["\r", "\n"], '', file_get_contents('/var/www/uploads/huge.b64'));
$decoded = '';
$chunkSize = 4 * 50000; // 4 文字の倍数、1 回の呼び出しあたり約 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 の 1 メガバイトは最新のハードウェアで 1 ミリ秒に満たない時間でデコードされるので、このループのコストはほぼゼロです。速度のためでなく、その検証と報告の性質のために選びましょう。

二つ目のツールはストリーム世界の住民です: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 はテキストの容器です。だからこそ、予想しない場所に現れます。データベースでは、バイナリ blob(ファイル、アイコン、直列化された構造)は 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

同じ注意がここには 2 度適用されます。第一に、これは形式の安全性であって、秘密性ではありません:開発者が設定ファイルを読む瞬間に、その値は 1 回の呼び出しでデコードできます。シークレットを Base64 で保存しておいて、それを暗号化したと呼んではいけません。第二に、起動時に検証してください:壊れた、あるいは半分貼り付けられただけの環境変数の値は strict な呼び出しによる false になり、1 行のチェックが、意味不明な実行時エラーを実行可能な起動メッセージに変えてくれます。

コマンドラインから

すべてのデコードが Web リクエストの中で行われるわけではありません。CLI スクリプト、cron ジョブ、ワンライナーはいつも Base64 をデコードしており、コマンドラインこそ、この関数が php://stdin と出会う場所です:

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

シェルにはすでに独自の Base64 ユティリティ(coreutils の base64 -d)があり、手軽な仕事には十分です。PHP ワンライナーは、次のステップが PHP のロジックのときに使います:データベースへの書き込み、API への呼び出し、検証の実行など。シェル特有の落とし穴が 2 つ。デコードの出力は生のバイトなので、バイトを理解するファイルやコマンドに送り、壊してしまうターミナルには送らないでください。そしてワンライナーでは strict フラグをオンに保ってください。ターミナルで切り詰められた貼り付けには、ゴミ 3 バイトではなく false がふさわしいからです。

PHP アクセントの落とし穴

PHP に固有の罠のクイックツアーを、ひとまとめに:

  • 緩いデフォルトが、いちばん大きな罠です。base64_decode('V@hpcy') は警告もなくゴミ 3 バイトを返すので、信頼できない入力のすべてのデコーダーは strict フラグと false チェックが必要です。
  • 単独の文字は緩いモードで空の文字列にデコードされ、空白文字だけの文字列もそうです。空の結果はほとんど何も証明しません:false だけが失敗を意味し、それが得られるのは strict モードだけです。
  • クエリ文字列の + は、PHP がそれを見る前にすでにスペースになっています。クライアントがパーセントエンコードせずに ?token=abc+def を送ると、PHP はあなたに abc def を手渡します(これはフォームエンコードの挙動で、parse_str() と urldecode() が共有しています)。そしてどれだけデコードの魔法を使っても、プラス記号は戻りません。URL 安全な Base64(プラス記号は一切なし)が、URL 中のトークンのための修正です。
  • 足りないパディングは、黙ってあなたのために補われます。7 文字は 8 文字のようにデコードされます。それは便利ですが、パッドが 1 つ 2 つ切り詰められたペイロードでも文句なくデコードできてしまうことを意味します。つまり、きれいなデコードはペイロードが完全に届いたことを決して証明してくれない(Go の raw エンコーダーも Java も同じほど寛容です)。
  • mbstring.func_overload の亡霊。長く非推奨とされたこの設定は、strlen() や仲間を文字を数えるように書き換えており(PHP 8.0 で削除)、UTF-8 文字列での Base64 バイト計算を壊していました。引き継ぐレガシーコードには、まだそれに関するコメントや回避策が残っているかもしれません。削除してください。
  • デコードされたバイトは UTF-8 文字列ではありません。/u フラグ付きの preg_match() や mb_substr() をデコードされたバイナリに実行すると、「不正な入力」エラーの即座の出どころになります。まず嗅ぎ分け、それから決めましょう。
  • null を渡すことは、PHP 8.1 から非推奨です。変数が null になり得るなら、呼び出し前に '' にしておいてください。
  • $_GET と仲間たちは、URL ルールではなくフォームルールでデコードされます。値がパーセントエンコードされて届いたなら、rawurldecode() がより安全な逆変換です。なぜなら + をそのままにしておくからです。

base64_decode の短い歴史

Base64 自身は、モダンな Web の大部分より古いです(それを統べる標準、RFC 4648 は 2006 年のもので、1996 年の MIME エンコードを規範化しました。それはさらに 1990 年代初頭の PEM アーマーに由来しています)。PHP の物語は、その小さな変更履歴そのものです。

PHP 4 は base64_decode() をオプションなし、strict モードなしのコア関数として搭載しました。緩いモードがただ一つのモードで、ディコーダーに文句を言うよう求める術はありませんでした。2006 年 11 月の PHP 5.2.0 が $strict フラグを追加し、その変更履歴の記述は読む価値があります:それは今日の RFC 4648 の前身である RFC 3548 の準拠を強制するために追加されました。その 1 つのフラグは、関数の生涯で最も役に立つ追加だと後から判明します。

その後、デバッグの年がやってきました。PHP 5.3 は、2 つのポイントリリースにわたって strict モードのバグの一連を修正しました:バグ #52327(strict モードで先頭のパディングが不適切に扱われる、5.3.4 で修正)とバグ #55273(strict モードでパディング後の空白が拒否される、5.3.9 で修正)。(2016 年の整数オーバーフローの修正も、この関数の名前で報告されています:バグ #72836、正式には「base64_decode の整数オーバーフローがヒープ破損を引き起こす」というタイトルで 5.6.25 で修正されましたが、バグレポート自身の再現コードと修正された関数は、実際のオーバーフローはデコーダーではなく base64_encode() の長さ計算にあったことを示しています。タイトルは元のリポートから引き継がれた誤称です。) 各修正は、上記の表で見る挙動をきめ細かくしていきました。PHP 8.0 が 2 つの Base64 関数にネイティブのパラメータと戻り型を与え、それがこの記事の冒頭で見たシグネチャです。同じリリースラインで、mbstring.func_overload、何年も黙ってバイト計算を壊してきた設定が削除されました。PHP 8.1 が null を渡すことを非推奨にしました。それ以来、この関数の表面は凍結しています:1 つのパラメータ、1 つのフラグ、1 つの戻り型、不変。

変わり者向けの余興

これは長めの参照記事なので、PHP に固有の、単に面白い事実をいくつか:

  • 空の恒等性。base64_encode('') と base64_decode('') はどちらも '' です。関数は両方向で、空を第一級値として扱います。false は関与しません。
  • 奇妙な住所。PHP マニュアルでは、2 つの Base64 関数は「その他の基本拡張機能」という本の「URLs」チャプターに暮らしています。専用の「エンコード」チャプターはありません。そちらで、そのチャプターのリストの一番上、parse_url() や仲間より前で、見つけることができます。
  • ディコーダーは同形写像です。php.net の定番ユーザーノートは、この関数は 4 剰余と 3 剰余の区切られた文字列の間の同形写像であると述べており、これは「4 の倍数での分割はすべて有効な分割である」という形式的な言い方です。それがチャンク化デコードのセクションがそもそも動く理由であり、1 MB のファイルを 50 KB のスライスのまま零損失でデコードできる理由です。
  • 1 つのパラメータ、1 つのフラグ。20 年以上にわたり、base64_decode() はちょうど 1 つのパラメータ($strict)を得て、base64_encode() は何も得ませんでした。
  • もっと古い兄弟がいます。同じコア拡張機能は、convert_uuencode() と convert_uudecode()(マニュアルでは String Functions(文字列関数)に記載)も抱えています。これは、uuencode がバイナリ伝送の定番だったダイヤルアップ時代の残骸です。ほとんど必要になることはありませんが、太古の .uu ファイルがいつかあなたの受信トレイに落ちてきても、PHP は開けます。
  • strict モードは、メールのために扉を開けておきます。4 つの空白文字(スペース、タブ、キャリッジリターン、改行)は意図的に strict モードを通過するので、MIME で折り返された添付ファイルは事前処理を必要としません。それ以外のすべて、NUL バイトを含め、false です。

もう一方の方向

これがデコーダー側で、最も多くの痛みがここに棲んでいます。なぜならデコードは、他の人々のデータに出会う場所だからです:彼らのパディングの選択、改行、文字コード、トークン。もう一方の方向、つまり base64_encode() でバイトを Base64 文字列にすることのほうが、穏やかな生き物です:失敗することはなく、strict モードもなく、そちら独自の罠の集合(二重エンコード、折り返しの不一致、サイズの代償)には別のガイドがあります。このページからリンクされる『PHP での Base64 エンコード』が、エンコーダーを同じ深さで扱います。

最終更新: 2026-10-09

関連記事: PHP での Base64 エンコード:完全ガイド