Java での Base64 デコード:完全ガイド
サポートチケットの中、API のレスポンスの中、Kubernetes のシークレットの中、あるいは URL の真ん中に埋もれた形でやってくるのが、この値です:文字と数字の長い列で、ときどき + や /、-、_ が混ざり、たいてい末尾に = が1つか2つぶら下がっている。誰かが「これは Base64 で、あなたに必要なものが含まれている」と言う:パスワード、JSON のペイロード、証明書、写真。このガイドは、それをもとに戻すための Java レシピです。方向感覚を少しだけ:フォーマットの詳しい解説はホームページにあるので、ここではざっと。Base64 はデータの3バイトを、64文字のアルファベットから選んだ4文字に書き換え、最後のチャンクが短いときは末尾に = を1つか2つつけます。デコードは、その取引の縮む方向です:4文字が入り、3バイトが出てくるので、結果は常に、入力の空間より約4分の1少なく済みます。
ここがヘッドラインで、しかもいい知らせです。2014年3月18日以降、すべての JDK が標準ライブラリに完全な Base64 ツールキットを同梱しています:java.util.Base64。ダウンロード不要、Maven コーディネート不要、ネイティブライブラリも不要。import は1つ、ファクトリメソッドが7つ、アルファベットが3つ、Java 8 から今日の Java 26 まで挙動は不変です。この記事のすべては、その1つのクラスの上に建てられています。
始める前に、正直な境界線を1つだけ:これは物語のデコーダー側です。出会うアルファベットに合う正しいデコーダーを選ぶ方法、JDK のエラーメッセージを医師が検査結果を読むように読む方法、文字化けなしでバイトをテキストに変換する方法、PEM のアーマーを剥ぐ方法、複数 GB のペイロードをストリーム処理する方法、そしてこのフォーマットが道に静かに仕掛けておいたセキュリティの罠を見抜く方法を学びます。逆方向、つまりバイトを文字列に詰める方には別のガイドがあり、この記事の末尾からリンクしています。
すでに手元にあるもの
Java で Base64 をインストールする方法は、ホワイトボードで即答するあの1行です:「JDK に入っている」。クラス java.util.Base64 は 1.8 以来 java.base モジュールの一部であり、javadoc は 2026 年でもなお Since: 1.8 と書かれています。インストールするのは JDK だけです。どのベンダー(Oracle、Eclipse Temurin、Amazon Corretto、Zulu)の Java 8 以降でも構いません。Debian 系のマシンでは1コマンドです:
sudo apt install openjdk-17-jdk-headless
この API はファクトリです:デコーダーを自分で構築するのではなく、クラスに1つくださいと頼みます。7つのファクトリメソッドは各方向に3つの気質を渡します。デコーダー側はこうなります:
| ファクトリメソッド | アルファベット | 気質 | こんなときに使う |
|---|---|---|---|
getDecoder() |
A-Z a-z 0-9 + / |
厳格:アルファベット外の文字はすべて拒否 | 自分で生成したり管理したりするデータ |
getUrlDecoder() |
A-Z a-z 0-9 - _ |
厳格、URL 安全アルファベット | JWT、トークン、ID、URL の中で生まれたもの全般 |
getMimeDecoder() |
A-Z a-z 0-9 + / |
寛容:アルファベット以外の文字はすべてスキップ | メール、本当にラップされた入力、PEM の本文 |
getEncoder()、getUrlEncoder()、getMimeEncoder() |
上記と同様 | エンコード、姉妹ガイドの管轄 | Base64 を読むのではなく生成するとき |
返されるインスタンスの性質のうち、覚える価値があるのは3つです。第一に、スレッドセーフです:javadoc はインスタンスは「複数の同時実行スレッドから安全に使える」と書いており、ソースコードを見ると、ファクトリメソッドは呼び出しのたびに同じ共有インスタンスを返しているので、Base64.getDecoder() == Base64.getDecoder() は true です。static フィールドに1つのデコーダーを作っておき、サービス全体で共有しましょう。何もコピーしていません。第二に、呼び出しの間は無状態なので、リセットすべきものも、同期すべきものもありません。第三に、バイト配列や文字列を期待する場所に null を渡しても、おとなしく何も起きるわけではありません:クラスの javadoc が約束どおり、NullPointerException が発生します。
コードベースでは古いライブラリにもまだ出会うので、簡単な地図を。Apache Commons Codec(現在の 1.22.1)は 1.0 以来、独自の実装 org.apache.commons.codec.binary.Base64 を持ち続けています。Builder API が、厳格か寛容かのポリシー、行の長さ、セパレータをダイヤルとして公開します。ただし、Java 8 以前の JVM をサポートしなければならない、あるいはその形チェックヘルパーが必要だというときだけ、それが正しいツールです。Guava は同じほど実力のある大ベテラン com.google.common.io.BaseEncoding を同梱しています。ビッグデータ基盤では今なお人気があります。モダンな JVM で動くものなら、java.util.Base64 がデフォルトの選択です:依存関係ゼロで、コミュニティのベンチマークは毎回、これが中身で最速だと結論づけています(それはパフォーマンスのセクションで詳しく)。
最初の文字列をデコードする
デコードの実生活の9割は、数行に収まります。RFC 自身がアルファベットを説明するのに使っている最小の例を使って、儀式の全体を示しましょう:
import java.nio.charset.StandardCharsets;
import java.util.Base64;
public class FirstDecode {
public static void main(String[] args) {
byte[] bytes = Base64.getDecoder().decode("TWFu");
String text = new String(bytes, StandardCharsets.UTF_8);
System.out.println(text); // Man
}
}
何が起きたか、4文で。第一に、入り口はクラスではなくインスタンスです:decode() はファクトリから手に入れた Base64.Decoder オブジェクトの上にあります。第二に、これはこの API 全体でいちばん重要な設計判断ですが、結果はバイト配列であり、String ではありません。ペイロードは一文でも、JPEG でも、ハッシュもありえ、手に入れたものが何かわからない前に、それらを同じ扱いにしてはならないため、JDK は意図的にバイトで止まります。第三に、バイトからテキストへのジャンプは、明示的な文字コードを持つ、別個の意図的なステップです。雑に扱ったら「café」が文字化けになるのがこのステップで、下の文字コードのセクションはそれに捧げられています。第四に、空文字列は一等市民の値です:Base64.getDecoder().decode("") は長さが0の配列を返し、例外も騒ぎもありません。
頭のなかに置くテストデータとして覚えておいてください:TWFu は標準自身のスモークテストです。あなたのデコードコードがこれを Man に変換するなら、機械は正直です。逆方向のラウンドトリップは同じ API の2行で済み、末尾からリンクするエンコードのガイドでじっくり扱います。
デコーダー陣
Java が渡すデコーダーは1つではありません。3つです。その違いは、どのアルファベットを受け入れ、どの程度の乱れを許すかというポリシーの判断です。3つとも同じ内部クラス Base64.Decoder のインスタンスです。クラスの javadoc は、気質ごとに1文ずつこの違いを明記しています。基本と URL 安全のデコーダーについて:デコーダーは「base64 アルファベットの外の文字を含むデータを拒否する」。MIME デコーダーについて:「base64 アルファベット表に見つからないすべての行区切り記号やその他の文字は、デコード操作では無視される」。その2文目が、MIME の物語全体を1行にまとめたもので、しかも歯があります。「無視」とは、改行だけでなく、アルファベット文字でないすべてを意味するからです。
選択ルールは短いです。デフォルトは getDecoder()。値が URL、トークン、あるいは「URL-safe」と約束した API から来たなら、getUrlDecoder() に切り替えます。本当に MIME 形状の入力(76文字ごとに改行、メールシステムからそのまま)が来ると思われるときだけ、getMimeDecoder() に手を伸ばします。迷ったら厳格を選んでください:厳格なデコーダーの役割は、驚きを失敗にすることです。信頼境界で欲しいのはまさにそれです。一方、寛容なデコーダーは、破損の拡大鏡です。余分な文字を混入した文字列は、ありえそうで間違ったものに変換されてしまい、エラーはまったく出ません。
デコーダーの文句を読む
厳格なデコーダーは、大きく失敗します。そして正確に失敗します。悪い入力のたびに、何が起きたかを正確に教えてくれる IllegalArgumentException が投げられるので、本番の文字列が初めて爆発したとき、読むのがこの表です。以下のメッセージは、現在の JDK の文言そのものです:
| 入力(特に断りがない限り getDecoder) | 何が悪いのか | 正確なメッセージ |
|---|---|---|
"SGVs bG8s" |
スペースが紛れ込んでいる | Illegal base64 character 20 |
"SGVs\nbG8s" |
改行が紛れ込んでいる | Illegal base64 character a |
"SGVs$bG8s" |
ドル記号はアルファベットにない | Illegal base64 character 24 |
"SGVsbG8-" |
標準デコーダーに URL 安全のダッシュ | Illegal base64 character 2d |
"ab+c" を getUrlDecoder() に |
URL 安全デコーダーにプラス記号 | Illegal base64 character 2b |
"S" |
記号1つではバイトを形成できない | Input byte[] should at least have 2 bytes for base64 bytes |
"SG=VsbG8s" |
データの真ん中にパディング | Input byte array has wrong 4-byte ending unit |
"Zm8==" |
パディングが1つのところを2つ | Input byte array has incorrect ending byte at 4 |
"Z=" |
1文字の後にパディング | Last unit does not have enough valid bits |
"SGVsbG8sIHdvcmxkIQ==xx" |
パディングの後にゴミ | Input byte array has incorrect ending byte at 20 |
メッセージの中のその16進数は、問題の文字のバイト値で、Integer.toString(byte, 16) で出力されます:20 はスペース、a は改行(LF)、d はキャリッジリターン(CR)、24 はドル記号、2d は URL 安全のダッシュ、2b はプラス、2f はスラッシュ、5f はアンダースコア。ポケットに入れておきたいクイークが2つあります。第一に、メッセージは負になりえます:é を含む文字列をデコーダーに与えると Illegal base64 character -17 と文句を言います。この文字はまず Latin-1 のバイト 0xE9 に写像され、符号付きの Java バイトとしてはマイナス23、それを16進で書くとマイナス17になるからです。あなたのエラーロガーは、一瞬だけ、符号付きの計算をしています。第二に、位置です:incorrect ending byte at N 系のメッセージでは、N はデコーダーが意味を理解できなかった最初のバイトの0始まりインデックスです。破損したペイロードをバイセクションで切り分けているときは、これが宝になります。
知っておくべき衣装替えが1つあります:デコードがラップされたストリーム(後述の wrap(InputStream) 版)を通って行われると、同じ問題は 0x 接頭辞付きの IOException として顔を出します:Illegal base64 character 0x20(現在の JDK です。JDK 8 のストリームデコーダーは、バイトではなく引き出し値 -1 を出力します)。同じ問題、違う例外、少し違う表記。そして寛容な MIME デコーダーは、もちろん、これらすべてに対して文句を言いません:単にスキップするだけです。それが寛容な気質の対価です。
パディングのルール
野生に生きる Base64 の文字列は、パディングについて沈黙した約束をしています。Java の約束は、異様に友好的です。デコーダーの javadoc は正確にこう言います:パディング文字 = は「受け入れられ、エンコードされたバイトデータの終わりと解釈されるが、必須ではない」。2文字または3文字の最終ユニットは、パディングされたかのようにデコードされ、パディングが存在するときは、正確に正しい量で存在しなければなりません。現在の JDK の、定番の例まわりの挙動:
| 入力 | 結果 |
|---|---|
"" |
空のバイト配列、エラーなし |
"Zm8" |
"fo"、パディングは単に不在 |
"Zm8=" |
"fo"、正規の表記 |
"Zm8==" |
IllegalArgumentException:incorrect ending byte at 4 |
"Zm9v=" |
IllegalArgumentException:wrong 4-byte ending unit |
"Zg==" |
"f"、バイト1つ |
"Z=" |
IllegalArgumentException:last unit does not have enough valid bits |
"AA==" |
ちょうどバイト1つ、NUL バイト 0x00 |
"AAAA" |
NUL バイト3つ |
その表を2回読んでください。空文字列は何もデコードされませんが、AA== は NUL バイト1つにデコードされます:Base64 では、「何もない」と「ゼロ」は別物であり、どちらも完全に有効な入力です。そしてパディングは、存在するときは正確でなければなりません:Zm8= は正しく、Zm8== は間違い、Zm9v= は間違い、文字列の真ん中にパディングも間違い。自分たちのプロトコルへの実用的な帰結:表記を1つ(パディング有無)選び、両端で強制してください。2つの表記で届く可能性のある値は、どこかで素朴な等価チェックを壊しうる値だからです。
base64url: URL のために作られたアルファベット
標準 Base64 のアルファベットは + と / で終わります。URL で正しく振る舞わないのは、まさにこの2文字です:クエリ文字列の中の + は、Java がそれをみるまえにすでにスペースになっており、/ はパスの区切り文字で、ぶら下った = は3文字の怪物になるパーセントエンコーディングを求めます。RFC 4648 セクション5が修正案を描きます:URL 安全かつファイル名安全なアルファベットで、+ が - になり、/ が _ になり、長さが暗黙のうちにわかっているときは、末尾の = パディングは通常落とされます。RFC は名前について断言します:このエンコーディングは「base64 エンコーディングと同じとは見なすべきではない」。あなたはそれを base64url として出会うことになります。JSON Web Token、OAuth の state パラメータ、API のセッション ID、11文字の動画 ID は、すべてこの方言の中に住んでいます。
ウェブでいちばん有名な base64url のペイロードは JWT で、その中をのぞき見るのは3行の作業です。トークンの各部分は慣習的にパディングなしで、URL デコーダーはそれで満足します。なぜなら、パディングは受け入れられるが必須ではないから、思い出してください:
import java.nio.charset.StandardCharsets;
import java.util.Base64;
public class JwtPeek {
public static void main(String[] args) {
String token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9"
+ ".eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ"
+ ".SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c";
String[] parts = token.split("\\.");
byte[] header = Base64.getUrlDecoder().decode(parts[0]);
byte[] payload = Base64.getUrlDecoder().decode(parts[1]);
System.out.println(new String(header, StandardCharsets.UTF_8));
// {"alg":"HS256","typ":"JWT"}
System.out.println(new String(payload, StandardCharsets.UTF_8));
// {"sub":"1234567890","name":"John Doe","iat":1516239022}
}
}
ここには正直な免責事項が2つ住んでいます。第一に、JWT をデコードするのは、のぞき見であって信頼ではありません:第3の部分は署名で、あなたが読んだ2つの部分は秘密でも、認証済みでもありません。署名を検証する前にペイロードを信頼するのは、古典的な JWT バグです。修正は、自分で暗号を作るのではなく、JJWT(0.13.0)や nimbus-jose-jwt(10.9.1)のような JOSE ライブラリに検証を任せることです。第二に、エラーは方向を特定します:標準アルファベットの文字列を getUrlDecoder() に渡すと Illegal base64 character 2b または 2f が返り、逆の場合は 2d または 5f になります。アルファベットの不一致は、野生でもっとも一般的な Base64 デコード失敗で、エラーメッセージは瞬時にそれを指し示します。クエリ文字列の中のトークンが本来標準 Base64 であるはずだったなら、その + と / は、あなたに届く前にトランスポートによって壊されていた可能性が高いです。デコードエラーは、あなたのデコーダーではなく、上流のバグについてあなたに伝えているのです。
バイトから言葉へ
この記事にあるデコード呼び出しはすべて、意図的にバイトで止まります。Base64 はバイト形式だからです。それ以上でも以下でもありません。「あのテキストは何だったのか」という質問に答えるのはあなたの役目で、モダンなデフォルトの答えは UTF-8 です。ただ、API のデコード側には、人々を驚かせる文字コードの細部が1つあるので、ここに出しておきます。decode(String) オーバーロードは、あなたの文字列を UTF-8 と解釈しません。javadoc は正確にこう言います:呼び出しは「decode(src.getBytes(StandardCharsets.ISO_8859_1)) を呼び出すのとまったく同じ効果を持つ」。それはバグではなく、トリックです:Base64 のアルファベットは純粋な ASCII なので、文字列を Latin-1 経由で写像すれば、変換コストゼロでデコーダーにまったく同じバイトを渡せます。入力の ASCII 以外の文字は単に無効な記号になり、厳格なデコーダーに拒否されます(エラーメッセージの負の16進数の出自はここです)。
ペイロードの文字コードは、まったく別の判断で、new String(bytes, charset) のステップでの判断です。ここが定番のケースです:UTF-8 の「café」は5バイトの 63 61 66 C3 A9 で、これをエンコードすると Y2Fmw6k= になります:
import java.nio.charset.StandardCharsets;
import java.util.Base64;
public class CharsetDecode {
public static void main(String[] args) {
byte[] packed = Base64.getDecoder().decode("Y2Fmw6k=");
System.out.println(new String(packed, StandardCharsets.UTF_8));
// café、アクセントは生き残る
System.out.println(new String(packed, StandardCharsets.ISO_8859_1));
// caf の後に文字化け、UTF-8 のバイトが Latin-1 として誤読される
}
}
その2行目が、瞬時に認識すべき失敗モードです:UTF-8 のペイロードを Latin-1 で読み、ぴったり1文字長く、1バイトずれた文字列が生まれます。処方箋はいつも同じ:生産側と文字コードで合意し、明示的に渡すことです。そして明示的に渡すのはコードの中でであり、頭の中だけでは不十分です:引数なしの new String(bytes) コンストラクタはプラットフォーム既定の文字コードを使い、Windows サーバーでは Cp1252、古い Linux ならマシンが気分次第で選ぶものが使われます。JDK 18(JEP 400、「UTF-8 by Default」)以降、すべてのプラットフォームで既定は UTF-8 なので、モダンな JVM では引数なしの形がたまたま正しいですが、それでもコードはそう書くべきです。次の読者は、既定値が何かを知る必要がないようにするためです。そしてペイロードがそもそもテキストでなければ、同じコードが異なる終わり方をするだけです:最後まで、バイトを入れ、バイトを出す。
ペイロードがファイルであるとき
もっともよくあるファイルの仕事は、あるエクスポート手順の逆です:.b64 テキストファイルが届き、元のファイルを戻す必要があります。厳格なデコードで、これはすでに本番の形をしています:
import java.nio.file.Files;
import java.nio.file.Paths;
import java.util.Base64;
public class DecodeFile {
public static void main(String[] args) throws Exception {
byte[] packed = Files.readAllBytes(Paths.get("payload.bin.b64"));
byte[] raw = Base64.getDecoder().decode(packed);
Files.write(Paths.get("payload.bin"), raw);
}
}
このパスのどこも、ペイロードがテキストファイルなのか、ZIP アーカイブなのか、動画なのかを気にしません:byte[] はただのバイトです。サイズの計算も味方です:デコードされた出力は、エンコードされた入力の長さの4分の3なので、デコードはメモリを悪くせず、数百 MB 級のエンコード済みファイルの方が小さい方です。いい習慣は、ラベルを信頼する前に、バイト自身に自己紹介させることです。PNG の先頭8バイトはいつもマジックナンバー 89 50 4E 47 0D 0A 1A 0A なので、あなたが出会う Base64 エンコード済みの PNG はすべて、同じ前置き iVBORw0K で始まります:ペイロードが画像だと「主張」するのに、そう始まらないなら、すでになんかおかしいです。
宛先のバッファをすでに持っていれば、2配列のオーバーロードはそこへ直接書き、何バイトが着いたかを正確に返し、中間アロケーションはありません:
import java.nio.charset.StandardCharsets;
import java.util.Base64;
public class DecodeInto {
public static void main(String[] args) {
byte[] src = "SGVsbG8sIHdvcmxkIQ==".getBytes(StandardCharsets.ISO_8859_1);
byte[] dst = new byte[16];
int written = Base64.getDecoder().decode(src, dst);
System.out.println(written); // 13
System.out.println(new String(dst, 0, written, StandardCharsets.UTF_8));
// Hello, world!
}
}
そのオーバーロードには、javadoc に記載されている尖った角が1つあります:宛先が小さすぎる場合、1バイトも書き込まれず、IllegalArgumentException: Output byte array is too small for decoding all input bytes が返ります。バッファのサイズは単純な計算で、おおよそ 3 * n / 4 からパディングを引いた値にしておけば、例外は顔を出しません。ByteBuffer オーバーロードもあり、limit をデコード長に設定した新しいバッファを返します。パイプラインが NIO に住んでいるときは便利です。
ワイヤーから:ヘッダー、JSON、data URI
Base64 が Java に最も頻繁に出会うのは、ネットワークのエッジです。3つの形状が、それぞれ1つずつの具体例に値します。
形状1: HTTP の Basic 認証ヘッダー。ウェブで最も古い認証ヘッダーは、まだ Base64 で動いています。RFC 7617 に従い、Basic リクエストは Authorization: Basic の後に username:password の Base64 エンコードを送り、RFC はこれが保護ではなくエンコーディングだと明確にしています:パケットキャプチャを持てば、誰でも1キーで両方の半分を読めます。RFC 自身の例 QWxhZGRpbjpvcGVuIHNlc2FtZQ== は Aladdin:open sesame にデコードされます。サーバー側でヘッダーを解析するのは数行の作業です:
import java.nio.charset.StandardCharsets;
import java.util.Base64;
public class BasicAuth {
public static String[] credentials(String header) {
if (header == null || !header.startsWith("Basic ")) {
return null;
}
byte[] packed = header.substring(6).getBytes(StandardCharsets.ISO_8859_1);
byte[] raw = Base64.getDecoder().decode(packed);
String userPass = new String(raw, StandardCharsets.UTF_8);
int colon = userPass.indexOf(':');
if (colon < 0) {
return null;
}
return new String[] {userPass.substring(0, colon), userPass.substring(colon + 1)};
}
}
この安全性を保つのが2つの細部です。最初のコロンで分割することが重要なのは、パスワードが合法に自分自身のコロンを含みうるからです。そしてデコードされたパスワードを保存済み値と比べるときは、定時間で行うべきです。両方の値を SHA-256 でハッシュし、ダイジェストを MessageDigest.isEqual で比較します。攻撃者が計時されてユーザーリストに変えられうる素の equals は決して使わないでください。これは HTTPS の上でのみ提供してください。プレーンな接続では、Base64 レイヤーは窓の装飾です。
形状2: JSON の中のバイナリ。モダンな API の多くは、JSON の中に Base64 テキストとしてバイナリを埋め込みます:ファイルアップロードのエンドポイント、コンテンツ API、シークレットストア、Webhook はみんなそうします。生のバイトは、さもなくば JSON 文字列のエスケープルールを壊すからです。パターンはいつも同じです:フィールドは素の文字列として届き、あなたは境界でデコードし、ドメインオブジェクトの中でではありません:
import java.util.Base64;
public class ApiField {
public static void main(String[] args) {
// 解析済みの JSON が持っていたのは: "content" : "iVBORw0KGgoAAA..."
String field = "iVBORw0KGgo=";
byte[] image = Base64.getUrlDecoder().decode(field);
// 標準 Base64 を話す API もあります。仕様を読んでから、
// それに合わせて getDecoder() か getUrlDecoder() を選びます。
System.out.println(image.length); // 8
}
}
ここでの落とし穴はデコードではなく、仕様を読むことです。標準 Base64 かつパディング付きを求める API もあれば、パディングなしの base64url を求める API もあり、両方に寛容な少数派もいます。仕様が沈黙しているときは、最安の修正は相手側の例値を眺めることです:値のどこかに - や _ があればアルファベットは決まり、末尾の = があればパディングは決まります。
形状3: data URI。誰かがフォームに画像を貼り付け、フロントエンドがあなたに data URI 全体を渡してきました:data:image/png;base64,iVBORw0KGgo...。RFC 2397 が形状を定義しています:data:、任意の media type、任意の ;base64 フラグ、カンマ、そしてデータ。フラグがあるときはペイロードは Base64、ないときはペイロードはパーセントエンコードされた素のテキストで、めったにありませんが合法です。media type が省略された場合、デフォルトは text/plain;charset=US-ASCII です。1つを分割するのは簡単です:
import java.util.Base64;
public class DataUri {
public static void main(String[] args) {
String uri = "data:image/png;base64,iVBORw0KGgo=";
int comma = uri.indexOf(',');
String meta = uri.substring(5, comma);
String payload = uri.substring(comma + 1);
boolean isBase64 = meta.endsWith(";base64");
String mime = isBase64 ? meta.substring(0, meta.length() - 7) : meta;
byte[] raw = Base64.getDecoder().decode(payload);
System.out.println(mime + " -> " + raw.length + " bytes");
// image/png -> 8 bytes
}
}
このフォーマットには落とし穴が2つ住んでいます。;base64 フラグの欠落が1つ目です:フラグのない合法的な data URI はパーセントエンコードされたペイロードを持ち、それを Base64.getDecoder() に通すと例外が投げられます。2つ目は主張された media type です:それは送信者からのヒントであり、事実ではありません。だから「png」の引き出しにしまう前に、デコードしたもののマジックバイトを確認してください。そして RFC 自身の助言を覚えておいてください:data URI は短い値のためにあります。URL の中の数 MB 級の画像は、パターンではなく悪臭です。
メール、MIME、PEM アーマー
Base64 はメールのために生まれました。そしてメール形状の Base64 は、今でも Java のプログラムにしょっちゅう届きます。MIME 標準(RFC 2045)は Base64 をバイナリの転送エンコーディングの1つとし、2つの家則を追加しました:エンコードされた行は76文字を超えてはならない、そしてデコーダーはアルファベット以外のすべての文字、改行も含めて無視しなければならない。厳格なデコーダーは最初の改行ですら拒否します。getMimeDecoder() はまさにこの入力のために作られました:
import java.nio.charset.StandardCharsets;
import java.util.Base64;
public class MimeDecode {
public static void main(String[] args) {
String wrapped = "SGVs\nbG8s\r\nIHN0\nYW5kYXJk";
byte[] bytes = Base64.getMimeDecoder().decode(wrapped);
System.out.println(new String(bytes, StandardCharsets.UTF_8));
// Hello, standard
}
}
それで動きます。でも、その裏には歯があるから、その落とし穴を知っておくべきです。寛容なデコーダーは「改行を無視する」のではなく、アルファベットにないものすべてを無視します。標準 Base64 の文字列が余分な文字で壊されると、ゴミは消え、残りはありえそうなものにデコードされます。だから getMimeDecoder() に手を伸ばすのは、本当に MIME 形状の入力が来ると予想しているときだけです。
野生では MIME の兄弟に PEM アーマーがあります:-----BEGIN CERTIFICATE----- のあれで、証明書とキーを包むものです。ここが罠です:アーマーの行はごく普通のアルファベット文字で満ちています。「BEGIN CERTIFICATE」の文字はただの Base64 の文字なので、アーマー込みで PEM ブロック全体を食わせると、アーマーがデータであるかのようにデコードされてしまいます。自分でアーマーを剥いでから、裸の本文をデコーダーに渡してください:
import java.util.Base64;
public class PemDecode {
public static void main(String[] args) {
String pem = "-----BEGIN CERTIFICATE-----\n"
+ "TUlJQm96Q0NBVWlnQXdJQkFnSUpBSXBhVDJUaVFvZU1BMEdDU3FHU0liM0RRRUE9\n"
+ "-----END CERTIFICATE-----\n";
String body = pem.replaceAll("(?m)^-----.*$", "").replaceAll("\\s", "");
byte[] der = Base64.getDecoder().decode(body);
System.out.println(der.length); // DER の本文、アーマーは除外済み
}
}
PEM は慣習的に1行64文字で折り返します(MIME は76)。空白がなくなれば、厳格なデコーダーと MIME デコーダーは結果で一致します。厳格な方を使いましょう:驚きには少なくとも、例外を投げる気配りがあるのです。汚くても標準的なケースでは、定番のレシピは既知の空白を剥ぎ落として厳格なインスタンスでデコードし、残ったゴミなら破損した証明書ではなく IllegalArgumentException を稼ぐことにすることです。
設定、環境変数、データベースの値
Base64 はテキストの容器です。それが、予期しない場所に現れる理由です。データベースでは、バイナリのブロブ(ファイル、アイコン、シリアライズされた構造体)は Base64 として TEXT 列に住むことができます。テキストを前提とするすべてのツールを生き抜いて、です。保存される値は元より約3分の1大きくなると見込め、それに合わせて列のサイズを決めましょう。設定ファイルや環境変数では、Base64 は、さもなくばフォーマットを壊す値を密輸入するためのトリックです:セミコロン付きの DSN、引用符付きのパスワード、複数行の証明書。起動時のデコードが仕事全体です:
import java.nio.charset.StandardCharsets;
import java.util.Base64;
public class ConfigDecode {
public static void main(String[] args) {
String value = System.getenv("DB_DSN_B64");
if (value == null) {
return;
}
byte[] raw = Base64.getDecoder().decode(value);
String dsn = new String(raw, StandardCharsets.UTF_8);
// dsn はこうなりうる:pg:host=db;password=qu"ote
}
}
同じ注意がここでは2回適用されます。第一に、これはフォーマットの安全であって、秘密性ではありません:開発者が設定ファイルを読んだ瞬間に、1回の呼び出しで値をデコードできます。だから秘密を Base64 として保存して、それを暗号化済みと称しないでください。下のセキュリティのセクションで詳しく扱います。第二に、起動時に検証してください:壊れた、あるいは半分に貼り付けられた環境変数の値は、厳格な呼び出しからの IllegalArgumentException になります。2行の確認が、意味の取りにくい実行時エラーを、対処可能な起動メッセージに変えてくれます。データベース界へ向けた Java 固有の注意が1つ:デコードしたバイナリは byte[] として保つこと(JDBC コードでは byte[] パラメータ)。そしてバイナリを String 経由でラウンドトリップさせることは絶対にしないこと。文字列コンストラクタは、バイナリペイロードが死ぬ場所だからです。
大きいものをストリームする
大きいが、それでもあなたが管理するバッファに入るペイロードには、配列 API で十分です。メモリに載せること自体があってはならないペイロードには、ストリームアダプターが一手です:wrap(InputStream) は、読み進めるにつれてデコードする入力ストリームを返します。そのため、複数 GB のエンコード済みファイルは、バイト配列の上に置く必要が永遠にありません:
import java.io.InputStream;
import java.io.OutputStream;
import java.nio.file.Files;
import java.nio.file.Paths;
import java.util.Base64;
public class StreamDecode {
public static void main(String[] args) throws Exception {
InputStream packed = Base64.getDecoder().wrap(Files.newInputStream(Paths.get("bigfile.b64")));
OutputStream raw = Files.newOutputStream(Paths.get("bigfile.bin"));
byte[] buf = new byte[8192];
int n;
while ((n = packed.read(buf)) != -1) {
raw.write(buf, 0, n);
}
raw.close();
packed.close();
}
}
知っておく価値のある細部が2つあります。ラップされたストリームの読み込みメソッドは、デコードできないバイトに出会うと IOException を投げます。そのため、壊れたファイルは IllegalArgumentException ではなくストリームの例外で失敗します。そしてラップされたストリームをクローズすると、その下のストリームもクローズされるので、例ではコピーループの後に packed を最後にクローズしています。本番では両方を try-with-resources ブロックに入れてください。(バッファの 8192 は、ただのゆったりした読み込みバッファです。ラップされたストリームは内部でデコードするので、読み込むサイズはパフォーマンス上の選択であり、プロトコルの要求ではありません。)
ここでレガシー警告を。これはこの物語全体で唯一の本物のバグで、しかもバグ番号があるからです。16 より前のすべての JDK で(バグレポートでは 8、10、11 で再現します)、特定のバッファサイズでラップされたデコーダーを読み込むと、デコードされたデータの末尾に2つの余分なゼロバイトが追加されます:JDK 8222187。定番の再現手順は、7バイトの読み込みバッファと素の8バイト入力の組み合わせで、JDK 16 で修正されました。レガシーな JDK 8 でストリーム処理しなければならないなら、コピーの後にデコード長を再確認してください。このバグは特定の「入力とバッファ」の組み合わせで発火し、4096 バイトのバッファでも野で報告されています。それとも、より良い方法としては、JDK をアップグレードすることです。どうせ100個くらいの他の問題も一緒に直りますから。
梱包テープであり、鍵ではない
ここからが、慎重な人とやけどを焼いた人を分けるセクションです。Base64 は暗号化ではありません。しかも標準自体が、それを2度繰り返して言っています。RFC 4648 セクション12: Base エンコーディングは「パスワードのように、さもないと簡単に見分けられる情報を視覚的に隠す」が、「計算による秘匿性は何も提供しない」、さらに、誰かがチケットにプロトコルのやり取りを貼り付けてパスワードをうっかり明かしたとき、これが「セキュリティインシデントを引き起こしたことがある」とも続きます。RFC の実装者への助言も額に入れて飾る価値があります:「デコーダーは、埋め込みの NUL 文字を含む無効な入力で壊れてはならない」。
よりこっそり潜む罠は、可変性です。各記号が6ビットを運ぶこと、短い最終ユニットが残す余分なビットが、正しく整形されたエンコーディングではゼロでなければならないことを思い出してください。ずぼらな、あるいは敵対的なエンコーダーは、その余分なビットにゴミを入れられます。しかも結果は完全に有効に見える:MQ== も MT== も、数字 1 の1バイトにデコードされます。Java はこれに寛容な側を取ります:Base64.getDecoder().decode("MT==") は非重要ビットを検証せず、快く同じバイトを渡します。なぜ気にする必要があるのか?同じデータにデコードされる2つの異なる文字列は、ハッシュチェック、重複排除、署名の比較が黙って頼っている「一意な表記」の前提を壊しますから。しかも、送信中のエンコード値を改ざんできる攻撃者は、1つの表記をもう1つの表記にすり替えることができます。2022年の論文「実践における Base64 の可変性」(Chatzigiannis と Chalkias、ACM ASIA CCS 2022) は、まさにこれらの不一致を実世界のさまざまな実装にわたって歩きます。RFC 自身の、余分なビットについての言葉:それらは「情報の漏洩に悪用されたり、文字列の等価比較を迂回するために使われたり、実装の問題を引き起こすために使われたりするかもしれない」。実用的なルールは「決してデコードしない」ことではなく、「自分の境界を知ること」です:自分たちのシステム間だけのデータなら、JDK の寛大さで十分です。しかし信頼境界を越えるデータには、デコードしたものを信頼する前に、正規形(正しい長さ、ゼロの余分なビット、パディングの表記は1つ)を強制してください。
パフォーマンスのメモ
朗報を1文で:モダンな JVM では、組み込みのデコーダーは Base64 がボトルネックになることはほとんどないほど速く、エコシステムの残り部分は、これに対して自身をベンチマークする基準です。一例を挙げます:2025年に gRPC-java プロジェクトは、Guava ベースの Base64 処理を java.util.Base64 と公開ベンチマークしました(issue 11857)。JDK 17 と 21 では、JDK 実装はエンコードで約2.5倍から3.8倍速く、デコードで1.3倍から2.1倍速く、x86 で差が最大でした。これは、JDK の実装の努力がどこに向かったかについての強力なヒントで、Base64 のベンチマークで見つけ出し続けるのと同じ結論です:今は標準ライブラリ版が速い方で、レガシー版ではありません。
実用的なメモが2つ。第一に、巨大なファイルでは、速度ではなくメモリプロファイルが、あなたが管理するものです。だからストリーミングのセクションが存在します:wrap(InputStream) はワーキングセットを読み込みバッファに収めます。第二に、本当に何百万もの小さな値をデコードするホットパスに足を踏み入れたなら、デコーダーインスタンスを1つ共有してください(ファクトリはすでに同じ共有インスタンスを返します。冒頭で述べた通り)、すでにバイトを持っているときは decode(String) オーバーロードをスキップしてください(最初に Latin-1 経由で文字列をコピーします)、そして decode(byte[], byte[]) オーバーロードに、あらかじめサイズを決めた宛先配列へ書かせて、アロケーションの踊りをスキップさせてください。
Java 訛りのある罠
1つの場所に集めた罠たち。すべて Java 固有です:
- アルファベットに合わないデコーダー。 base64url の文字列を
getDecoder()に(またはその逆)渡すのが、定番のIllegal base64 characterクラッシュで、メッセージにはたいてい2d、5f、2b、2fのどれかが入っています。毎回、プロトコルに合うデコーダーを合わせること。 - 野生からの末尾の空白。 ターミナル、環境変数、設定ファイルからコピーした値は、改行つきで届くことが多いです。厳格なデコーダーはそれを
Illegal base64 character aに変えます。入力をstrip()するか、データが本当にラップされているときにだけ MIME デコーダーを使いましょう。 - アーマーの罠。
getMimeDecoder()は PEM ヘッダーを理解しません。BEGIN や CERTIFICATE の文字はデータとしてデコードされます。常に、自分でアーマーの行を剥ぎ落とすこと。 - 「安全のため」の MIME 寛容さ。 安全確保だけで MIME デコーダーを使ってデコードすると、余分なアルファベット外の文字は黙ってスキップされます。壊れたペイロードがありえそうで間違ったものとして出てくることもあります。本物の MIME 入力のみに使いましょう。
- 文字コードを運に任せる。 引数なしの
new String(bytes)はプラットフォーム既定の文字コードを使います。JDK 18 以降は UTF-8 ですが、コードはStandardCharsets.UTF_8を明示的に渡すべきです。さもなくば、次のサーバー移行の後に文字化けを楽しめます。 - バイナリを文字列化する。
new String(decodedPng)にして戻すのは、データの破壊です:あなたの文字コードで有効でないバイト列はすべて置換文字になり、ラウンドトリップは片道です。最後まで、バイトを入れ、バイトを出す。 - 余分なビットへの信頼。
MT==はMQ==とまったく同じようにデコードされます。そのため、非重要ビットにゴミを隠したペイロードは、JDK が実行するすべてのチェックを通過します。プロトコルが重要なら、正規形を強制してください。 - JDK 8、11、12 のストリーム。 それらのバージョンのラップされたデコーダーは、特定のバッファサイズで2つの余分なゼロバイトを追加することがあります(JDK 8222187、16 で修正)。16 以降では問題ありません。古いバージョンでは問題です。
- null は空ではない。
nullをdecode()に渡すのはNullPointerExceptionで、空の配列ではありません。変数が null になりうるなら、呼び出し前に null でない値に置き換えておいてください。 - Android は別の動物園。 Android では、
java.util.Base64は API レベル 26 からしか存在しません。それ以下では、フレームワークのクラスは独自のフラグ定数を持つandroid.util.Base64です。チェックなしで片方をハードコードしたコードは、あなたが決してテストしなかったデバイスで、まさにそこで壊れます。 - セキュリティではないことを忘れる。 Base64 がパスワードを隠すのは、一瞥に対してだけで、それ以外の誰に対しても隠せません。データが秘密なら、まず暗号化し、そのうえでチャネルがテキストを要求するなら、はじめてパックしてください。
java.util.Base64 への長い道
フォーマットの物語は Java より古いです。1980年代、インターネットのメール基盤は7ビット ASCII しか運べませんでした。バイナリを動かしたい人々は、ローカルな方言を発明しました:UNIX 用の uuencode(そのアルファベットは連続した ASCII コードを縦断するので、エンコードは32の1回の足し算だけで、ルックアップテーブルは不要)と、Apple マシン用の BinHex(アルファベットを厳選し、7、O、g、o のような視覚的に紛らわしい文字を落としました)。1987年、Privacy-Enhanced Mail プロトコル(RFC 989)が証明書を運ぶための64文字スキームを、64文字の行長とともに標準化しました。そして1993年の RFC 1421 は、そのアルファベットとパディングのルールを保持しました。1996年、MIME(RFC 2045、1993年の RFC 1521 の更新)は、64文字のアルファベットにちなんですでに「base64」と名付けられたこのスキームを運び、あなたのメール添付ファイルを今もラップし続けている76文字の行長を決め、getMimeDecoder() が今日まで実装し続けている寛容なデコーダーのルールを書きました。2003年、RFC 3548 はこのファミリー全体を整理しようとし、デコーダーはアルファベット外の文字を拒否すべきだと宣言しました。そして2006年、RFC 4648 が誰もが引用する標準になりました:アルファベットの表、セクション5の base64url バリアント、そしてこの記事の最後のセクションたちを正直に保つセキュリティのセクションとともに。
Java 自身の章は、少しだけ劇的です。何年にもわたって、JDK 内部の Base64 は内部ペア sun.misc.BASE64Encoder と sun.misc.BASE64Decoder ただ1つで、今日コンパイルできて、非推奨の警告もなく消えるタイプの API でした。XML の世界で Base64 が必要なら、JAXB の javax.xml.bind.DatatypeConverter もありました。それ以外のみんなは Apache Commons Codec か Guava を使っていました。そして2014年3月18日:Java 8 が java.util.Base64 を出荷しました。RFC 4648 と RFC 2045 を、あなたがずっと使ってきたファクトリメソッドのパターンで1つのクラスに実装したものです。3年半後、Java 9(2017年9月21日)が sun.misc ペアを永久に削除しました。公式の移行ガイドはこれを率直に言っています:「特筆すべきは、sun.misc.BASE64Encoder と sun.misc.BASE64Decoder が削除されたことです。代わりに、JDK 8 で追加されたサポートされた java.util.Base64 クラスを使用してください」。それらをまだ参照している古いコードに jdeps を実行すると、ツールはその依存を「JDK removed internal API」とフラグ立てます。Java 11 はその後、JAXB モジュールとその DatatypeConverter も一緒に削除しました(JEP 320)。1.8 以来、公開 API は1つのメソッドたりとも変わっておらず、javadoc は依然として最初の Since: 1.8 タグを背負っています。動いたのはその下にいるエンジンです:バグ修正(JDK 8222187 のストリームバグ、JDK 16 で修正)とパフォーマンス作業。だからこそコミュニティのベンチマークは同じ結論に着地し続けています。12年、1つの API。そしてそれは今も、あなたが払わなくて済む Base64 の中では最速です。
豆知識、Java 版
完全なガイドは笑顔で終わるべきなので、単に楽しい Java 固有の事実をいくつか:
decode(byte[] src, byte[] dst)の Oracle javadoc は、「IllegalargumentException がスローされる前に、出力バイト配列に一部のバイトが書き込まれている可能性がある」と約束しています。IllegalArgumentException ではなく、IllegalargumentException ですね。a が小文字です。このタイポは実際の JDK ソースにあり、2014年からそうでした。このくらいタイポにコミットしたドキュメントは、あるべき頻度よりずっと希少です。éを含む文字列をデコードすると、エラーメッセージはIllegal base64 character -17になります:負の16進数です。その文字は Latin-1 のバイト0xE9になり、符号付きの Java バイトとしてはマイナス23、JDK はそれを16進で出力するからです。あなたのエラーロガーは、一瞬だけ、符号付きの計算をしています。Base64.getDecoder() == Base64.getDecoder()は true です。ソースコードは呼び出しのたびに共有 static インスタンスを返すので、「新しいのをください」API はシングルトンの衣装で、スレッドセーフの約束は、JVM がすでにやっていることの説明にすぎません。- URL デコーダーにアンダースコア4文字の文字列
"____"を食わせると、純粋な0xFFの3バイトを返します。アンダースコアはアルファベット値63で、4つで24ビット、1が24ビットはバイト3連FF FF FFです。そこに違法なものは何もありません。いちばん面白いのがそこです。 AA==は NUL バイト1つにデコードされ、空文字列は何にもデコードされません。Base64 では、「何もない」と「ゼロ」は別物で、どちらも完全に有効な入力です。Manにデコードされる小さな文字列TWFuは、エコシステムのお気に入りのスモークテストになりました:RFC に、Wikipedia に、リファレンスマニュアルに、地球上のほとんどの Base64 チュートリアルに出てくるので、それ以来書かれたすべてのデコーダーが、同じ小さな敬意を捧げ続けています。- あなたがデコードしてきたすべての Base64 エンコード済みの PNG は
iVBORw0Kで始まります。それは変装した PNG マジックナンバーで、インターネットでもっとも識別しやすい8文字の前置きの1つです。 - RFC 4648 の URL 安全セクションで、「base64url」という名前は生まれます:仕様は、このエンコーディングは「base64url と呼べるかもしれない」と言い、警告として「base64 エンコーディングと同じとは見なすべきではない」と書いています。URL 安全アルファベットの出自は、2001年の P2P-hackers メーリングリストへの投稿に脚注されています。だからあなたがすべての URL に貼り付けるこの名前は、メーリングリストの出所を持つのです。
- YouTube の動画 ID はパディングなしの base64url で、URL のどこにでも貼り付けられるお馴染みの11文字の文字列です。メール添付のために設計されたフォーマットが今や動画プラットフォームを回し、
getUrlDecoder()はそれを可能にするあなたの JDK の部品です。 - 文字列
YmFzZTY0をデコードすると、単語base64が戻ってきます。パディング不要です。6は3の倍数なので。自らを記述するフォーマットは、モールス信号で話す鏡の技術的な同物です。
逆の方向
これが物語のデコーダー側です。痛みはここにあります。デコードとは、他人のデータに出会う場所だからです:彼らのパディングの選択、改行、文字コード、トークン、アーマー。逆の方向、java.util.Base64 のエンコーダーでバイトを Base64 文字列に変換する方、は穏やかな動物です:無効な入力で例外を投げることがありません(エンコードする無効な入力など存在しない)、読まなければならないエラーメッセージの代わりに、払わなければならないサイズの請求書があり、独自の罠のセット(文字コードのステップ、MIME のダイヤル、トークンのパディング判断)には独自のガイドがあります。このページからリンクされる「Java での Base64 エンコード」は、同じ深さでエンコーダーを扱います。2つを1組として読めば、快適に読めます。
最終更新: 2026-10-09