Base64 パディング徹底理解:`=` が必要な場面、省略可な場面、ライブラリ間の差

約9分

Base64 文字列の末尾に = が並ぶ理由を「長さを 4 の倍数に揃えるため」とだけ覚えている人は多いですが、実装で踏むのは「省略していい場面とダメな場面」「ライブラリの解釈差」のほうです。本記事では RFC 4648 の規定を起点に、padding の存在意義から省略可の境界、各言語のライブラリの挙動差まで掘り下げます。

なぜ = が必要か

Base64 は 3 バイト(24 ビット)を 4 文字(6 ビット × 4)に変換します。入力バイト数が 3 の倍数でないとき、エンコード後の最後のグループが 4 文字に満たなくなる。それを = で 4 文字に揃えるのが padding です。

入力バイト数 mod 3出力末尾例 (A = 0x41)
0パディングなしAAA (3 byte) → QUFB
1== 2 個A (1 byte) → QQ==
2= 1 個AA (2 byte) → QUE=

つまり = の数が 0/1/2 のいずれかで、入力長 mod 3 を後付けで判別できるのが padding の機能です。

RFC 4648 が言っていること

RFC 4648 は Base64 / Base32 / Base16 の正式仕様です。padding に関する記述は §3.2:

The pad character ”=” is typically percent-encoded when used in an URI [9], but if the data length is known implicitly, this can be avoided by skipping the padding;

つまり「データ長が暗黙的に分かっている場面では、padding は省略してよい」という規定です。具体例として §5(Base64url)が挙げる JWT・データ URL 中の Base64 などは、別の機構で長さを伝えるので padding なしで運用されます。

逆に、padding 必須なのは:

  • 単独の Base64 文字列をストリームで読む場面(どこで終わるか不明)
  • 連結した複数の Base64 ブロックを扱う場面(境界マーカーが要る)

「データ長が暗黙的に分かる」とは何かをもう少し具体化すると、HTTP body の Content-Length や、URL のパスセグメント終端JSON の文字列値の閉じクォートなど、外側のフレーミングで終端が決まる場合を指します。

JWT が padding を省略する理由

JWT は Base64url で 3 つのパート(header / payload / signature)を . で区切って連結します:

eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOiJqb2huIn0.AbCdEf...

. が境界マーカーなので、各パートの終端は明確で padding 不要。むしろ = を残すと URL に乗せたときに == がクエリ境界に化ける問題があります。RFC 7515 §2 の Base64url Encoding 定義に「padding は省略する」と明記。

ライブラリごとの解釈差

ここが本番。仕様で省略可 と書かれていても、ライブラリごとに入力に padding があるかないかで挙動が変わります。

言語 / ライブラリpadded 入力unpadded 入力
Node.js Buffer.from(s, 'base64url')✓ 受理✓ 受理
Node.js Buffer.from(s, 'base64')✓ 受理✓ 受理(緩い)
Python base64.urlsafe_b64decode✓ 受理binascii.Error
Python base64.b64decode✓ 受理
Java Base64.getUrlDecoder()✓ 受理✓ 受理
Java Base64.getDecoder()✓ 受理✓ 受理
Go base64.URLEncoding.DecodeString✓ 受理✗ エラー
Go base64.RawURLEncoding.DecodeString✗ エラー✓ 受理
PHP base64_decode✓ 受理✓ 受理
Ruby Base64.urlsafe_decode64✓ 受理✗ エラー(2.4 以前)

Python Go 系は厳格、Java Node は緩い、というのが大筋。「Node でテストして OK だったコードを Python に移植したら落ちた」 は典型的な遭遇パターンです。

安全な書き方

JWT のように padding なしで届く可能性があるトークンを扱うときは、デコードの前に padding を補うのが堅実:

function padBase64url(s) {
	return s + '='.repeat((4 - (s.length % 4)) % 4);
}

// Python
function padBase64urlPy(s) {
	const pad = (4 - (s.length % 4)) % 4;
	return s + '=' * pad;
}

逆方向(padding を取る)は単純に replace(/=+$/, '') でよい。

RFC 4648 が認める「padding 緩和」と認めないこと

  • §3.2 Padding:データ長が外で分かるなら省略可
  • §3.3 Interpretation of non-alphabet characters:仕様では = の位置は厳密に末尾のみ。中間に = が出てくるのは無効
  • §3.5 Canonical Encoding:「省略可な場合に常に省略する」「最後のクワッド未使用ビットは 0 にする」という”正準形”も定義されている

つまり「padding を省略してよい」のと「= をどこに置いてもよい」は別の話。末尾以外の = は構文エラーとして弾くべきです。

Base64 の末尾未使用ビット問題

実は padding の話とは別に、最後のクワッド(4 文字グループ)の未使用ビットにも仕様の罠があります。

入力 1 バイト(8 ビット)→ 出力 2 文字(12 ビット)の場合、余分な 4 ビットがあります。RFC 4648 の正準形ではこの余分ビットは 0 でなければなりませんが、ゼロでない値を含む Base64 をデコードできてしまうライブラリが多くあります。

Base64: "QQ"  (= の前) → 0x41 + 余 4 ビット
Base64: "QR"  ( 0x41 と異なる4ビット) → やはり 0x41 にデコードされる

"QQ""QR" が両方 "A" にデコードされる、という現象です。攻撃者がこれを使うと、異なる Base64 文字列が同じバイト列にデコードされる(malleability、いわゆる「2 つの Base64」攻撃)。

JWT の署名検証で「base64url から復号した bytes」を比較する実装は、入口側で正準形を強制(厳格モードで decode して、入力と再エンコード結果が一致するか確認)するのが安全です。

実用上の指針

  • JWT・OAuth・WebPush など、= が省略されている可能性のあるトークン → デコード前に padding を補う
  • HTTP API のパラメータで Base64 を運ぶ → 自分で生成する側は padding 付き、受信側は両対応で
  • データ URL (data:image/png;base64,...) → padding 付きが慣習(RFC 2397 は明示的に要求していないが多くのブラウザが厳格)
  • 暗号系の署名・MAC → 正準形(最終クワッドの未使用ビット = 0)を強制し、再エンコード一致を検証

まとめ

Base64 の = は「長さを 4 の倍数に揃える」だけの文字ではなく、入力バイト数の mod 3 を後付けで判別する識別子です。RFC 4648 §3.2 は「データ長が外で分かるなら省略可」と認めており、JWT などはこれを使っていますが、省略を受け入れるかどうかはライブラリ次第で、Python / Go は厳格・Node / Java は緩い。デコード前に手で padding を補うのが最大公約数の安全策です。

実際に文字列を変換しながら挙動を試したい場合は Base64 ツール で確認できます。