Base64 パディング徹底理解:`=` が必要な場面、省略可な場面、ライブラリ間の差
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 ツール で確認できます。