Base64がデータを常にちょうど33%膨張させる理由(数学的解説)
Base64は8ビットのバイト列であるバイナリデータを受け取り、A–Z、a–z、0–9に2つの追加文字(通常は+と/)を加えた64種類の文字だけを使ってテキストとして再エンコードします。64という数の選択は数学的に意図されたものです。64はちょうど2の6乗なので、出力文字1つが入力の6ビットを表現します。エンコーダは元のビットストリームを6ビット単位のかたまりに区切り直し、各かたまりを64文字のアルファベットのいずれかにマッピングします。
サイズのオーバーヘッドは単純な算術から導かれます。入力3バイトは24ビットであり、これはちょうど4つの6ビットグループに割り切れます。つまり入力3バイトごとに出力4文字が生成される — 4/3の比率、すなわち+33.33%のサイズ増加です。入力長が3の倍数でない場合、エンコーダは最後のグループをゼロビットでパディングし、最後の4文字組に実際に何バイト含まれていたかを受信側に伝えるために=を1つまたは2つ付加します。=が1つなら最後の4文字組は2バイト分をエンコードし、==なら1バイト分、=がなければきれいな3バイトグループだったことを意味します。
これでいくつかのよくある観察が説明できます。base64文字列の長さは常に4の倍数です。パディングは多くの実装で機能的には省略可能であり(デコーダは長さの剰余から推測できます)、そのためJWTや現代のURLセーフな文脈では「パディングを取り除いた」Base64をよく見かけます。厳密なRFC 4648準拠ではパディングの存在が必須です。現実の多くのソフトウェアは入力には寛容で、出力には厳格です。
33%という数字は漸近的な値です。非常に小さな入力ではパディングの切り上げによりオーバーヘッドはさらに大きくなります — 1バイトをエンコードすると4文字が生成され、そのバイトだけを見れば300%の膨張です。数百バイト以上の入力になると、オーバーヘッドは33.33%に漸近します。印字可能なASCIIの範囲内にとどまりながらこれより優れた方式は存在しません。Base85(Adobe PDFで使用)はオーバーヘッド25%を達成しますが、バックスラッシュや引用符といったJSONやURLで壊れる文字を使います。Base64はコンパクトさと可搬性の実用的なスイートスポットに位置しています。
Input bytes: M a n
|_______|_______|_______|
Binary: 01001101 01100001 01101110
Re-grouped: 010011 010110 000101 101110
Lookup A-Z..: T W F u
Output: "TWFu"
// 1 byte: pads with ==
btoa("M"); // "TQ=="
// 2 bytes: pads with =
btoa("Ma"); // "TWE="
// 3 bytes: clean
btoa("Man"); // "TWFu"
// length always % 4 === 0
btoa("hello world").length; // 16
btoa("a").length; // 4 (was 1 byte → 4 chars)URLセーフBase64と標準Base64の違い、そしてbtoaの罠
RFC 4648は2つのアルファベットを定義しています。標準Base64は最後の2文字に+と/を使い、パディングに=を使います。URLセーフBase64(「Base64URL」とも呼ばれます)は+を-に、/を_に置き換え、通常は末尾の=パディングも取り除きます。理由は単純です。+、/、=はいずれもURL、HTTPフォームボディ、一部のファイルシステムで予約された意味を持ちます。標準Base64文字列をエスケープせずにクエリパラメータに入れると最初の+の位置で壊れ、サーバーはそれをリテラルの空白としてデコードしてしまいます。
これがJWTがヘッダー / ペイロード / 署名の各セグメントにBase64URLだけを使う理由です。トークンはHTTPのAuthorizationヘッダー、Cookie、URLの中を運ばれる必要があり、これらはすべて標準アルファベットと相性が悪いのです。誤った方言を使うと、受信側でパースエラーになるか、さらに悪い場合には「ほぼ動く」トークンができてしまいます。サーバーが一部のコードパスでだけ+を空白に正規化し、他のパスではしない、といったことが起きるからです。
ブラウザのbtoa / atobはURLセーフBase64より前に生まれたもので、標準アルファベットしか生成しません。さらに悪いことに、Latin-1の入力しか受け付けません。U+00FFを超える文字(非ASCII / 非Latin-1の文字で、ほとんどのCJK文字や絵文字を含む)を含む文字列を渡すとInvalidCharacterErrorが投げられます。2026年における正しい手順は、まずTextEncoderで文字列をUTF-8バイトにエンコードし、次にそのバイトをBase64エンコードし、必要に応じてURLセーフ形式に変換してパディングを取り除くことです。デコードは逆順に行います。
Node.jsはこれをより簡潔に扱えます。Buffer.from(str, 'utf-8').toString('base64url')はURLセーフBase64を直接生成し、パディングもなく、文字セットの落とし穴もありません。ブラウザでは、提案中のUint8Array.prototype.toBase64() / fromBase64()(2026年時点でTC39ステージ3)がついにクリーンな組み込み手段を提供します。それがあなたの最低サポートブラウザに行き渡るまでは、下記のTextEncoder + btoa + replaceの組み合わせが定石の回避策です。
手短な安全上の注意です。Base64をセキュリティの意味で「エンコード済み」だと決して思わないでください。暗号化ではなく、意味のある難読化ですらありません。5秒の時間とどんなツールでもあれば、誰でもバイト列を復元できます。転送のために使い、実際の暗号技術の代わりとして使ってはいけません。
// Browser: UTF-8 safe Base64URL (no padding)
function toBase64Url(str) {
const bytes = new TextEncoder().encode(str);
let bin = '';
for (const b of bytes) bin += String.fromCharCode(b);
return btoa(bin).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}
function fromBase64Url(s) {
s = s.replace(/-/g, '+').replace(/_/g, '/');
while (s.length % 4) s += '=';
const bin = atob(s);
const bytes = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
return new TextDecoder().decode(bytes);
}
// btoa breaks on non-Latin-1
btoa('한글'); // InvalidCharacterError
toBase64Url('한글'); // "7ZWc6riA" (works)
// Node 16+
Buffer.from('한글', 'utf-8').toString('base64url'); // "7ZWc6riA"Base64を使うべきでない場面 — 3つの実例
Base64には適所がありますが、開発者はしばしばそれを適所の外へ持ち出して損をします。よく害が益を上回る3つの場面を挙げます。
(1) data URLによる画像のインライン化。4 KBのアイコンを<img src="data:image/png;base64,...">として埋め込みたくなる誘惑は強いものです。バイトコストは+33%ですが、本当のダメージはキャッシュ無効化です。個別にインライン化された資産はそれを含むページバンドルの一部になるため、アイコンを1つ更新しただけで、それが入っているHTMLドキュメントやCSSファイル全体のキャッシュが破棄されます。さらに悪いことに、ブラウザはそのアイコンをページ間で共有できません。各ページがインラインのバイト列を再ダウンロードします。HTTP/2とHTTP/3では外部ファイルのリクエストあたりのオーバーヘッドが十分小さいため、損益分岐点はおよそ1〜2 KBで、それを超えるとインライン化はあらゆる指標で不利になります。インライン化に手を伸ばすのは、変更されないことが保証され、同じクリティカルパスのページに必ず現れることが保証された、ごく小さなSVGやCSS内のdata URIに限定してください。
(2) 「容量節約」のために構造化データをBase64のblobに「エンコード」する。Base64はデータを33%大きくするのであって、小さくはしません。JSON.stringifyしてからBase64にし、さらにGzipする、という処理をしているなら、真ん中のステップを外してください。gzipは生のテキストでも問題なく機能し、Base64の層はgzipに圧縮すべき冗長なパターンを増やすだけで、エンコード時間を余計に消費し、生のJSONをgzipした場合より圧縮率も悪くなります。Base64はテキスト専用チャネルの向こうへバイナリを運ぶためのものです。チャネルがすでにテキストなら必要ありません。
(3) ログに記録されるURLにJWTやセッショントークンを保存する。Base64URLのアルファベットはURLセーフですが、いかなるプライバシーの意味でもログセーフではありません。Webサーバー、プロキシ、CDNはデフォルトで完全なリクエストURLをログに残します。?token=にセッショントークンを詰め込んだ「ログアウトリンク」は、そのトークンをアクセスログへ永久に漏らし続け、Base64のラッパーはそれを何も保護しません。その値が公開されても構わないもの(ワンタイム、短命)として扱うか、HTTPボディ、HttpOnlyクッキー、あるいはプロキシがマスキングするよう設定されたAuthorizationヘッダーへ移してください。
一貫した結論はこうです。Base64が解決するのは転送の問題であって、保存・セキュリティ・圧縮の問題ではありません。バイナリのペイロードとテキスト専用チャネルが揃ったときにだけ正確に使い、それ以外の場所では使わないでください。
// BAD: data URL for a logo on every page
<img src="data:image/png;base64,iVBORw0KGgoAAAA...(20KB)..." />
// → busts HTML cache on every change, no cross-page reuse
// GOOD: external file with long cache TTL
<img src="/static/logo.v3.png" />
// → CDN caches once, reused everywhere
// BAD: redundant Base64 in a compression pipeline
const blob = btoa(JSON.stringify(data));
const compressed = gzip(blob); // gzip is now LESS effective
// GOOD: gzip the JSON directly
const compressed = gzip(JSON.stringify(data));
// BAD: token in URL → leaked to access logs
<a href="/logout?token=eyJhbGciOi...">
// GOOD: Authorization header
fetch('/logout', { method: 'POST', headers: { Authorization: 'Bearer ' + token } });