メインコンテンツへスキップ
#Web制作 約10分で読めます

btoa('こんにちは')はなぜ落ちるのか。日本語Base64の文字化けをTextEncoderで根本から直す

btoa('こんにちは')はInvalidCharacterErrorで落ちる。Base64はバイト列の表現で、Latin-1前提のbtoa/atobに日本語を通すにはTextEncoder/TextDecoderを挟む。文字化けの正体からBase64URL・data URIまで実装者目線で整理した。

フォームの入力内容を確認画面にクエリパラメータで引き継ぐ、というよくある実装をしていた。btoa(JSON.stringify(form))でBase64にしてURLに載せる、受け取り側でatobして戻す。英数字のテストデータでは完璧に動いた。

動作確認の最後に、名前欄に自分の名前を漢字で入れた瞬間、コンソールが赤くなった。

Uncaught InvalidCharacterError: Failed to execute 'btoa' on 'Window':
The string to be encoded contains characters outside of the Latin1 range.

btoa('こんにちは')と打つだけで再現する。Base64なんて枯れた技術で、しかも「エンコードするだけ」で例外が出るとは思っていなかったので、最初は自分のJSONの方を疑って時間を溶かした。犯人はJSONではなく、btoaそのものだった。

Base64は「文字」ではなく「バイト列」を変換するもの

エラーメッセージに答えが書いてある。characters outside of the Latin1 range。btoaはLatin-1(1文字=1バイト)の範囲の文字列しか受け付けない。

なぜそうなっているかを理解するには、Base64が何をするものかを一段掘る必要がある。Base64は「文字列を文字列に変換する」ものではなく、任意のバイト列を、64種類の安全な文字(A-Z a-z 0-9 + /)だけで表現し直すものだ。3バイトを4文字に対応させるので、サイズは4/3倍、約1.33倍に膨らむ。

つまりBase64エンコードの入力は本来「バイト列」であって「文字列」ではない。ところがbtoaが設計された大昔のJavaScriptには、バイト列を表す良い型がなかった。そこで「文字列の各文字を、その文字コード(0〜255)のバイトとみなす」という苦肉のインターフェースになった。文字コードが255を超える文字、つまり日本語や絵文字が来た時点で「そのままではバイトにできない」ので例外、というわけだ。

言い換えると、btoaは壊れているのではなく、「文字列→バイト列」の変換を呼び出し側に丸投げしている。日本語をBase64にするには、その丸投げされた部分、つまり「どの文字コードでバイト列にするか」を自分で決めて実行する必要がある。

正解: TextEncoder / TextDecoderを挟む

現代のブラウザには文字列⇔UTF-8バイト列の変換専用API、TextEncoder / TextDecoderがある。これを挟むのが正解だ。

// エンコード: 文字列 → UTF-8バイト列 → Base64
function encodeBase64(text) {
  const bytes = new TextEncoder().encode(text); // Uint8Array
  let bin = '';
  for (const b of bytes) bin += String.fromCharCode(b);
  return btoa(bin);
}

// デコード: Base64 → バイト列 → UTF-8として文字列に
function decodeBase64(b64) {
  const bytes = Uint8Array.from(atob(b64), c => c.charCodeAt(0));
  return new TextDecoder().decode(bytes);
}

encodeBase64('こんにちは'); // "44GT44KT44Gr44Gh44Gv"
decodeBase64('44GT44KT44Gr44Gh44Gv'); // "こんにちは"

よく見るString.fromCharCode(...bytes)のスプレッド一発版は、短い文字列なら動くが、入力が大きくなると引数の数の上限に当たってRangeErrorで落ちる。後述の自作ツールではファイルも扱うので、実装ではバイト列を32KBずつに区切って変換している。ループで1文字ずつ足すか、チャンクに分けるかのどちらかにしておくと安全だ。

補足を2つ。Node.jsならこの問題自体が存在せず、Buffer.from(text, 'utf8').toString('base64')で最初からエンコーディングを指定できる。またブラウザ側も、Uint8Array.prototype.toBase64() / Uint8Array.fromBase64()という新しい標準APIが主要ブラウザに載り始めていて、これが行き渡ればbtoaを経由する回りくどさは消える。ただ2026年時点で古めの環境も考えるなら、TextEncoder + btoaの組み合わせがまだ無難だと思う。

atob側は例外ではなく「化けて」返ってくる。こっちの方がタチが悪い

btoaは例外で止まるのでまだ分かりやすい。厄介なのはデコード側だ。他のシステムが正しくUTF-8でBase64化した44GT44KT44Gr44Gh44Gvatobに渡すと、例外は出ずにã\x81\x93ã\x82\x93...のような、ãだらけの謎の文字列が返ってくる。

atobはBase64をバイト列に戻し、各バイトをLatin-1の1文字として文字列にする。UTF-8の日本語は1文字が3バイトなので、その3バイトがバラバラに3つのLatin-1文字として解釈される。これが「デコードは成功したのに文字化けする」の正体で、直し方はエンコードと対称、atobの結果を文字列として使わずバイト列に戻してTextDecoderに通す(上のコードのdecodeBase64)。

ついでに言うと、化け方には種類がある。UTF-8のバイト列をShift_JISとして読むと「こんにちは」は「縺薙s縺ォ縺。縺ッ」になる。メール処理や古いWindows系システムとBase64をやり取りして化けたときは、Base64そのものではなく「中身のバイト列を何のエンコーディングで読むか」が食い違っている。Base64の中身がUTF-8であることを送る側と受ける側で揃える。日本語Base64のトラブルは、結局ぜんぶこの一文に帰着する。

JWTのBase64が普通のデコーダで通らない理由(Base64URL)

Base64の変換で地味に混乱するのがもうひとつ、Base64URLの存在だ。標準Base64の64文字には+/が含まれ、末尾にパディング=が付く。この3つはURLの中で特別な意味を持ったりエンコードが必要になったりするので、URLに載せる用途では+-に、/_に置き換え、=を省略した変種が使われる。それがBase64URLで、代表的な利用者がJWTだ。

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9   ← JWTのヘッダー部(Base64URL)

JWTを標準Base64しか知らないデコーダに貼ると、-_が不正文字扱いされたり、パディングがなくて長さ不正になったりする。「JWT デコードできない」の相当数はこれが原因だと思う。JWTを調べる機会が多いなら、期限切れ判定や署名検証までやってくれるJWTデコード・検証ツールを別に作ってあるのでそちらが早い。単発のBase64URL文字列なら、Base64ツール側でも形式スイッチで両対応にしてある。

「Base64にしたから見えない」は1秒で破られる

これは仕組みの話というより注意喚起。Base64は見た目がランダムな文字列になるので、暗号化と誤解されることがある。APIキーをBase64にして設定ファイルに書く、パスワードをBase64にしてクエリに載せる、という実装を実際に見たことがあるが、Base64は鍵のない可逆変換なので、誰でもワンライナーで元に戻せる。BasicAuthのAuthorization: Basic ...ヘッダも中身はただのユーザー名:パスワードのBase64で、だからこそHTTPSが必須になっている。秘匿したいならBase64ではなく暗号化、照合だけならハッシュ化。Base64が担うのは「バイナリを安全な文字だけで運ぶ」ことだけだ。

data URIの使いどころと1.33倍のトレードオフ

逆に、Base64が本領を発揮する実用例がdata URIだ。小さい画像をBase64にしてHTMLやCSSに直接埋め込むと、その画像のためのHTTPリクエストが丸ごと消える。

<img src="data:image/png;base64,iVBORw0KGgo..." alt="icon">

このブログでも、OGP画像の生成まわりでSVGに画像を埋め込むのにこの形式を使っている。ただし冒頭に書いた通りBase64はデータを約1.33倍に膨らませるし、HTMLに埋め込んだ分はブラウザの画像キャッシュも効かない。感覚としては数KBのアイコンやロゴまでで、写真サイズの画像を埋め込むとHTML自体が肥大化して逆効果になる。埋め込む前に画像圧縮ツールで元のバイト数を削っておくと、1.33倍の税金がだいぶ軽くなる。

毎回コンソールでやるのが面倒になって、ツールにした

ここまでの変換、一つひとつは数行のコードだ。ただ「日本語入りのBase64をちょっと確認したい」「このdata URIの中身は何のファイルか見たい」がデバッグ中に度々発生して、そのたびにコンソールでdecodeBase64を書き直すのが面倒になった。外部の変換サイトは、業務のデータを貼ることを考えると気が進まない。

なのでBase64エンコード・デコードツールを自分のサイトに作った。この記事で踏んだ地雷をそのまま仕様に反映してある。

  • 変換はTextEncoder / TextDecoder方式。日本語・絵文字が化けない
  • 標準Base64とBase64URLをスイッチで切替。デコードはどちらの文字が来ても受け付ける
  • data:image/png;base64,のプレフィックス付き、MIMEの76文字折返し(改行入り)、パディング=欠けもそのまま貼ってデコードできる
  • デコード結果がUTF-8のテキストでない場合は、化けた文字列を出す代わりにhexダンプで中身を表示する
  • ファイルをドロップするとBase64とdata URIを生成。逆にBase64からファイルとしてダウンロードもできる
  • 処理は全部ブラウザ内で完結し、テキストもファイルも外部に送信しない

不正な文字が混ざっていた場合に「何文字目のどの文字か」までエラーに出すようにしたのは、自分がコピペ欠けのBase64で悩んだ経験からだ。btoaが落ちた日の自分に渡したかった道具として作ったので、同じエラーにたどり着いた人はどうぞ。

#Base64 #JavaScript #文字化け #TextEncoder #自作ツール
シェア