Base64 の仕組み入門 - 3バイトを4文字にする変換と、base64url・btoa の落とし穴

Base64 の仕組み入門 - 3バイトを4文字にする変換と、base64url・btoa の落とし穴

作成日:
読了:約27分
更新日:
この記事を読む人におすすめPR / Amazonアソシエイト

当サイトは Amazon.co.jp を宣伝しリンクすることで紹介料を得る手段を提供する、Amazonアソシエイト・プログラムの参加者です。価格・在庫はリンク先の最新情報をご確認ください。

画像を CSS に直接埋め込む data:image/png;base64,...、HTTP の Authorization: Basic QWxh...、JWT の eyJhbGci...。どれも中身は Base64 です。毎日のように目にするのに、「なぜ = が付くのか」「なぜ JWT には = が無いのか」「なぜ btoa('日本語') はエラーになるのか」と聞かれると、意外と説明しにくいものです。

この記事では、Base64 の変換手順を1文字ずつ追い、パディングとサイズの計算、派生形式の base64url、JavaScript・Node.js・コマンドラインでの扱い方と落とし穴までを、RFC 4648 などの一次情報をもとに整理します。

Base64 が必要になる理由

コンピュータの中のデータは、最終的にはバイト(8ビット)の並びです。画像や圧縮ファイルのバイト列には、0x00 から 0xFF までのあらゆる値が現れます。

ところが、世の中には「テキストしか通さない」経路がたくさんあります。

  • 電子メール: RFC 2045 が書いているとおり、RFC 821 の SMTP はメッセージを7ビットの US-ASCII に制限していました。添付ファイルのバイト列をそのまま流すことはできません
  • JSON: バイナリ型がありません。文字列として入れるしかありません
  • URL や HTML 属性・CSS: 使える文字や、特別な意味を持つ文字が決まっています
  • HTTP ヘッダー: 値に入れられる文字が限られています

Base64 は、任意のバイト列を「どの経路でも壊れにくい64種類の文字」だけで表す符号化です。MIME を定めた RFC 2045 は、この64文字を選んだ理由として、ISO 646 のすべての版(US-ASCII を含む)と EBCDIC のすべての版で同じように表現できること、SMTP で特別な意味を持つ . や CR・LF、マルチパートの区切りに使う - を含まないことを挙げています。

大事なのは、Base64 は暗号化でも圧縮でもないという点です。誰でも元に戻せますし、サイズはむしろ増えます。目的はあくまで「テキストの経路にバイナリを安全に載せること」です。

3バイトを4文字にする仕組み

Base64 の「64」は 2 の 6 乗です。つまり1文字で6ビットを表します。一方、バイトは8ビットです。8 と 6 の最小公倍数は 24 なので、3バイト(24ビット)をまとめて、6ビットずつ4文字にするのが基本の単位になります。RFC 4648 の Section 4 も、24ビットの入力グループを4文字の出力に対応させる、と説明しています。

6ビットの値(0〜63)は、次の表で文字に置き換えます(RFC 4648 Table 1)。

値文字
0〜25A〜Z
26〜51a〜z
52〜610〜9
62+
63/
(パディング)=

手で "Man" を変換してみる

RFC でもよく使われる例として、ASCII の文字列 Man を変換してみます。

文字           M          a          n
バイト(16進)   0x4D       0x61       0x6E
ビット         01001101   01100001   01101110
 
24ビットを6ビットずつに区切り直す
               010011   010110   000101   101110
値             19       22       5        46
文字           T        W        F        u
 
結果: "Man" → "TWFu"

やっていることはこれだけです。バイトの境目を無視してビットを並べ、6ビットごとに切り直して表を引きます。デコードはこの逆で、文字を6ビットに戻して並べ、8ビットごとに切り直します。

端数とパディング =

入力が3バイトの倍数でないときは、最後に端数が出ます。RFC 4648 は次のように処理すると定めています。

  1. 足りないビットは右側に 0 を足して6ビットの倍数にする
  2. 出力が4文字に満たない分は = で埋める

入力の最後のグループが何バイトかで、パターンは3つしかありません。

最後のグループ出力例
3バイト(24ビット)4文字、= なしMan → TWFu
2バイト(16ビット)3文字 + = 1個Ma → TWE=
1バイト(8ビット)2文字 + = 2個M → TQ==

Ma の場合、16ビットは 010011 010110 0001 となり、最後の4ビットに 0 を2つ足して 000100(4 = E)にします。M の場合は 010011 01 の残り2ビットに 0 を4つ足して 010000(16 = Q)です。

RFC 4648 Section 10 にはテストベクタが載っているので、実装を確かめるときに便利です。

BASE64("")       = ""
BASE64("f")      = "Zg=="
BASE64("fo")     = "Zm8="
BASE64("foo")    = "Zm9v"
BASE64("foob")   = "Zm9vYg=="
BASE64("fooba")  = "Zm9vYmE="
BASE64("foobar") = "Zm9vYmFy"

補足: 足した 0 のビット(パッドビット)について、RFC 4648 Section 3.5 は「エンコーダは 0 にしなければならない(MUST)」としています。一方でデコーダ側は、0 でないものを拒否してもよい(MAY)という扱いです。ブラウザの atob が従う WHATWG Infra の forgiving-base64 decode は余ったビットを捨てるので、仕様の注記にあるとおり YQ と YR はどちらも a になります。同じバイト列に複数の Base64 表現がありうる、という点は文字列比較で検証するときに注意が必要です。

サイズは約 4/3 になる

3バイトが4文字になるので、出力の長さは次の式で求められます(パディングありの場合)。

出力文字数 = 4 × ceil(入力バイト数 / 3)

おおよそ 4/3 倍、つまり約33%増です。RFC 2045 も「元データより常に約33%大きくなる」と書いています。1MB の画像を Base64 にすると、約1.33MB になります。

メールの場合はさらに改行が入ります。後述のとおり MIME では1行76文字までなので、76文字(=57バイト分)ごとに CRLF の2バイトが加わり、78 / 57 で約1.37倍になります。

デモ: 入力した文字列を Base64 にする

テキストを入力すると、UTF-8 のバイト列から6ビット区切り、Base64 と base64url の結果までを表示します。Man から始めて、Ma、M、日本語 などに変えてみてください。

▶ Base64 変換の途中経過(UTF-8 のバイト列から)
文字を入力すると、UTF-8 のバイト列を 3 バイトずつ 24 ビットにまとめ、6 ビットずつ区切って文字に置き換える過程を表示します。黄色の 0 は端数を埋めるために足したビットです。
1 〜 3 バイト目
0x4D M
01001101
0x61 a
01100001
0x6E n
01101110
010011
19
T
010110
22
W
000101
5
F
101110
46
u
base64: TWFu
base64url(パディングなし): TWFu
入力 3 バイト -> 出力 4 文字(1.33 倍)

デモのロジックは、空文字列・RFC 4648 のテストベクタ・日本語・絵文字と、0〜49バイトのランダムなバイト列2,000件で、Node.js の Buffer の base64 / base64url 出力と一致することを確認しています。

MIME の76文字改行と、RFC 4648 の「改行を入れない」

Base64 には、使われる文脈によって細部が違うという厄介な面があります。代表的なのが改行と、アルファベット外の文字の扱いです。

  • MIME(RFC 2045 Section 6.8): 符号化した出力は1行76文字以下でなければならない。デコード時は、表にない文字(改行など)を無視しなければならない
  • PEM: RFC 4648 によると、もともとのメール向け PEM は1行64文字でした。証明書や鍵のテキスト形式を定めた RFC 7468 も、最終行以外をちょうど64文字で折り返すよう求めています
  • RFC 4648 Section 3.1 / 3.3: 参照する仕様が明示しない限り、改行を入れてはならない(MUST NOT)。アルファベット外の文字を含むデータは拒否しなければならない(MUST)

つまり「Base64」とひとことで言っても、メール由来の寛容なデコーダと、RFC 4648 準拠の厳格なデコーダでは、改行や不正な文字を含む入力の扱いが違います。これが後述する「改行混入」のトラブルの原因になります。

RFC 4648 の仲間たち: base64url・base32・base16

RFC 4648(2006年10月、RFC 3548 を廃止)は、Base64 だけでなく複数の「Base-N」符号化をまとめて定めています。

名前使う文字1文字のビット数サイズパディング
base64(Section 4)A-Z a-z 0-9 + /6約4/3倍=
base64url(Section 5)A-Z a-z 0-9 - _6約4/3倍=(省略されることが多い)
base32(Section 6)A-Z 2-75約8/5倍=
base32hex(Section 7)0-9 A-V5約8/5倍=
base16(Section 8)0-9 A-F42倍不要

base64url

標準の Base64 は + と / を使いますが、どちらも URL やファイル名では特別な意味を持ちます。そこで、62番目と63番目の文字だけを - と _ に置き換えたのが base64url です。それ以外は Base64 と同じです。

RFC 4648 は、base64url を単に「base64」と呼ぶべきではない、とわざわざ書いています。また、= は URI の中ではパーセントエンコードされるのが普通だが、データの長さが別の方法で分かるならパディングを省略して避けられる、としています。

同じ2バイト 0xFB 0xFF を変換すると違いがよく分かります。

base64    : +/8=
base64url : -_8=   (パディングを省略すると -_8)

base32 と base16

base32 は、RFC 4648 によれば「大文字小文字を区別しない必要がある」環境向けの形式です。1文字5ビットなので、5バイト(40ビット)を8文字にするのが単位で、サイズは約1.6倍になります。ULID が使う Crockford の Base32 は、RFC 4648 の base32 とは文字の選び方が違う別の方式です(UUID と ULID 入門を参照)。

base16 はいわゆる16進数表記(hex)です。1バイトを2文字にするので2倍になりますが、境目が分かりやすく、ハッシュ値の表示などで広く使われています。

実際に使われている場所

data URI(RFC 2397)

小さな画像やフォントを HTML や CSS に直接埋め込むときに使う data: URL です。RFC 2397(1998年8月)の構文は次のとおりです。

data:[<mediatype>][;base64],<data>

;base64 があればデータ部分は Base64、なければパーセントエンコードされたテキストとして扱われます。たとえば data:text/plain;base64,SGVsbG8= は Hello というテキストを表します。なお、ブラウザでの data: URL の処理は、現在は WHATWG の Fetch Standard(data: URL processor)でも定義されています。

埋め込めばリクエストが1つ減りますが、サイズは約33%増え、画像だけを別にキャッシュすることもできなくなります。大きな画像には向きません。

HTTP Basic 認証(RFC 7617)

Basic 認証は、ユーザーID:パスワード をつないだ文字列を Base64 にして送ります。RFC 7617 の例では、ユーザーID Aladdin、パスワード open sesame が次のヘッダーになります。

Authorization: Basic QWxhZGRpbjpvcGVuIHNlc2FtZQ==

RFC 7617 は、Basic 認証自体は安全な認証方式ではなく、資格情報が平文で送られるのと同じだと明記しています。Base64 はデコードすればそのまま読めるので、HTTPS(TLS)と組み合わせることが前提です。

もう1つの注意点は文字コードです。RFC 7617 によると、元の定義は ユーザーID:パスワード をバイト列にするときの文字コードを決めていませんでした。そのため、サーバーは WWW-Authenticate に charset="UTF-8" を付けて UTF-8 を希望できるようになっています。Base64 が扱うのはあくまでバイト列なので、「文字列を Base64 にする」ときは必ず、その前にどの文字コードでバイト列にするかが問題になります(文字コードと UTF-8 / Unicode の基礎を参照)。

JWT(RFC 7515 の base64url)

JWT(正確には JWS 形式)は、ヘッダー・ペイロード・署名をそれぞれ base64url にして . でつないだものです。RFC 7515 は「Base64url Encoding」を、RFC 4648 Section 5 の文字を使い、末尾の = をすべて省略し、改行や空白を入れない形式として定義しています。

Buffer.from(JSON.stringify({ alg: 'HS256', typ: 'JWT' })).toString('base64url')
// 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9'

JWT がたいてい eyJ で始まるのは、{" を Base64 にすると eyJ になるからです。ペイロードも同じく誰でもデコードして読めるので、秘密の情報は入れないのが原則です。詳しくは JWT の仕組みと正しい使い方で解説しています。

メールの添付ファイル

メールの添付ファイルや、日本語の本文は、Content-Transfer-Encoding: base64 を指定して Base64 で送られることがよくあります。メールの送信元を確かめる仕組みについては メール送信ドメイン認証 入門にまとめています。

また、証明書や公開鍵のファイルで見かける -----BEGIN CERTIFICATE----- で囲まれた部分も、中身はバイナリ(DER)を Base64 にしたものです(RFC 7468)。公開鍵の仕組み自体は 公開鍵暗号とデジタル署名 入門を参照してください。

JavaScript での扱い方

btoa / atob と、日本語でのエラー

ブラウザ(と Node.js、Deno)には昔から btoa(エンコード)と atob(デコード)があります。ただし HTML Standard の定義では、btoa が受け取るのは「U+0000〜U+00FF の文字だけでできた文字列」で、1文字を1バイトとみなしてBase64 にします。U+00FF を超える文字が1つでもあると InvalidCharacterError の DOMException を投げます。

btoa('Man')        // 'TWFu'
btoa('日本語')     // InvalidCharacterError

Node.js 24.11.0 で実行すると InvalidCharacterError / Invalid character、Deno 2.6.7 では InvalidCharacterError / Cannot encode string: string contains characters outside of the Latin1 range というメッセージでした。

日本語を扱うには、先に TextEncoder で UTF-8 のバイト列にし、そのバイトを1文字ずつ U+0000〜U+00FF の文字に詰め替えてから btoa に渡します。

// エンコード
const bytes = new TextEncoder().encode('日本語')
// Uint8Array(9) [230, 151, 165, 230, 156, 172, 232, 170, 158]
const binary = Array.from(bytes, (b) => String.fromCharCode(b)).join('')
const b64 = btoa(binary)
// '5pel5pys6Kqe'
 
// デコード
const decoded = Uint8Array.from(atob(b64), (c) => c.charCodeAt(0))
new TextDecoder().decode(decoded)
// '日本語'

atob は WHATWG Infra の forgiving-base64 decode に従うので、空白や改行を取り除き、パディングが欠けていても受け付けます。実際に atob('TWE') も atob('TW E=') も 'Ma' を返しました。一方で base64url の - _ は受け付けず、atob('TWFu-_') は InvalidCharacterError になります。

Uint8Array.prototype.toBase64 と Uint8Array.fromBase64(ES2026)

上の詰め替えは面倒で、大きなデータでは効率もよくありません。そこで追加されたのが、Uint8Array と Base64 / Hex を直接変換するメソッドです。

  • TC39 の提案「Uint8Array to/from Base64」は Stage 4 に到達し、finished-proposals では公開予定年が 2026 となっています
  • 2026年6月の ECMAScript 2026(ECMA-262 第17版)の仕様に Uint8Array.prototype.toBase64 などが含まれています
  • MDN の互換性データ(browser-compat-data)では、Chrome 140、Firefox 133、Safari 18.2、Node.js 25.0.0、Deno 2.5.0、Bun 1.1.22 で対応しています。web-features では 2025年9月5日に Baseline(Newly available)になっています
const bytes = new TextEncoder().encode('日本語')
bytes.toBase64()
// '5pel5pys6Kqe'
 
new Uint8Array([0xfb, 0xff]).toBase64()
// '+/8='
new Uint8Array([0xfb, 0xff]).toBase64({ alphabet: 'base64url' })
// '-_8='
new Uint8Array([0xfb, 0xff]).toBase64({ alphabet: 'base64url', omitPadding: true })
// '-_8'
 
new TextDecoder().decode(Uint8Array.fromBase64('5pel5pys6Kqe'))
// '日本語'
Uint8Array.fromBase64('-_8', { alphabet: 'base64url' })
// Uint8Array(2) [251, 255]
Uint8Array.fromBase64('-_8')
// SyntaxError(既定の alphabet は 'base64' なので - と _ は不正)

デコード時の末尾の扱いは lastChunkHandling で選べます。既定の 'loose' はパディングの欠けを許し(Uint8Array.fromBase64('TWF') は [77, 97])、'strict' はパディングを含めてちょうど4文字のチャンクであることと、前述のパッドビットが 0 であることを要求します('TWF' は SyntaxError)。ほかに、最後のチャンクが4文字に満たなければその手前でデコードを止める 'stop-before-partial' もあります。また、改行などの空白は無視されます(Uint8Array.fromBase64('TW\nFu') は [77, 97, 110])。

上の結果は Deno 2.6.7 で実行したものです。手元の Node.js 24.11.0 では toBase64 は未定義で、V8 のフラグ --js-base-64 を付けると同じ結果になりました。Node.js でフラグなしで使えるのは、MDN のデータでは 25.0.0 からです。ES2026 のほかの新機能は ES2026(ECMAScript 2026)の新機能でまとめています。

Node.js の Buffer

Node.js では、Buffer で文字コードを指定して変換するのが定番です。'base64url' は v15.7.0 / v14.18.0 で追加されました。

Buffer.from('日本語').toString('base64')          // '5pel5pys6Kqe'
Buffer.from([0xfb, 0xff]).toString('base64')      // '+/8='
Buffer.from([0xfb, 0xff]).toString('base64url')   // '-_8'(パディングなし)
Buffer.from('5pel5pys6Kqe', 'base64').toString()  // '日本語'
Buffer.from('-_8', 'base64')                      // <Buffer fb ff>

Node.js のドキュメントによると、'base64' でデコードするときは base64url の文字も受け付け、空白や改行は無視します。'base64url' でエンコードするとパディングを省略します。

かなり寛容な実装で、Node.js 24.11.0 で Buffer.from('aGVsbG8!!!', 'base64').toString() を試すと、エラーにならず 'hello' が返りました。入力の検証が必要な場面では、Buffer のデコードに頼らず別途チェックするか、Uint8Array.fromBase64 を使う方が安全です。

コマンドラインの base64(macOS と GNU の違い)

base64 コマンドは macOS にも Linux(GNU coreutils)にもありますが、オプションが違います。macOS 26.5 の /usr/bin/base64 と、GNU coreutils 9.9 の base64 で確認しました。

用途macOS(/usr/bin/base64)GNU coreutils
デコード-d または -D-d
折り返し既定は折り返さない。-b 76 で76文字ごと既定で76文字ごと。-w 0 で折り返さない
入力ファイル-i ファイル引数にファイル名
-i の意味入力ファイルの指定--ignore-garbage(デコード時に不正な文字を無視)

100バイトのファイルを変換すると、macOS は136文字の1行、GNU は既定で76文字と60文字の2行になりました。Linux のスクリプトを macOS に持っていくと、-w 0 や -i の意味の違いで動かなくなることがあります。

不正な入力の扱いも違います。aGVsbG8!!! をデコードすると、GNU の base64 -d は invalid input で終了コード1、-i を付けると hello を出力しました。macOS の base64 -d は hel までを出力して終了コード0でした。

GNU coreutils には、base64url や base32 も扱える basenc コマンドもあります(basenc --base64url など)。

よくある誤解と落とし穴

Base64 は暗号化ではない

RFC 4648 の Security Considerations は、Base64 などの符号化はパスワードのような情報を見た目では分かりにくくするが、計算上の機密性は一切提供しない、と書いています。別の問題を説明するつもりでネットワークのやり取りの詳細を共有し、Base64 で守られていると思い込んでいたパスワードを漏らしてしまう事故が起きてきた、という注意まで添えられています。

設定ファイルなどで値が Base64 になっていても、それは「テキストとして扱いやすくするため」であって、隠すためではありません。

URL に載せると + と / と = が壊れる

標準の Base64 をそのまま URL のクエリに入れると、+ が空白として解釈されることがあります。Node.js 24.11.0 で試すと次のとおりです。

new URLSearchParams('token=+/8=').get('token')
// ' /8='(+ が空白になった)
encodeURIComponent('+/8=')
// '%2B%2F8%3D'

URL やファイル名に載せるなら、最初から base64url を使うのが簡単です。標準の Base64 を使うなら encodeURIComponent でエンコードします。

改行が混ざる

GNU の base64 は既定で76文字ごとに改行します。その出力を環境変数や JSON、HTTP ヘッダーにそのまま入れると、RFC 4648 準拠の厳格なデコーダでは不正な文字として拒否されることがあります。逆に MIME 由来の寛容な実装は改行を無視するので、「ある環境では動くのに別の環境では動かない」という分かりにくい不具合になります。1行で必要なときは base64 -w 0(GNU)を使い、受け取る側でも空白の扱いを確認しておきます。

base64 と base64url を混同する

JWT のトークンを atob でそのままデコードしようとすると、- や _ を含む部分で InvalidCharacterError になります。base64url は - _ を + / に戻し、必要ならパディングを補ってからデコードするか、Uint8Array.fromBase64(s, { alphabet: 'base64url' }) を使います。

圧縮のつもりで使う

Base64 はサイズを約33%増やします。データを小さくしたいなら、先に gzip などで圧縮してから Base64 にします。圧縮の仕組みは データ圧縮の仕組み 入門で解説しています。

まとめ

  • Base64 は、任意のバイト列を64種類の安全な文字だけで表す符号化。暗号化でも圧縮でもない
  • 3バイト(24ビット)を6ビットずつ4文字にするのが基本。端数は 0 のビットで埋め、= で4文字にそろえる
  • 出力は 4 × ceil(n / 3) 文字で約33%増。MIME は1行76文字で折り返す
  • base64url は + / を - _ に替えたもの。JWT はさらにパディングを省略する
  • JavaScript の btoa は Latin1 の範囲しか受け付けない。日本語は TextEncoder を経由するか、ES2026 の Uint8Array.prototype.toBase64 を使う
  • 実装ごとに、改行・不正な文字・パディングの扱いが違う。コマンドも macOS と GNU でオプションが違う

参考リンク

文字コードと UTF-8 / Unicode の基礎 - 文字化けの原因と、length が合わない理由

文字コードと UTF-8 / Unicode の基礎 - 文字化けの原因と、length が合わない理由

約11分

文字コードを実務目線で整理します。Unicode(文字集合・コードポイント)と UTF-8/UTF-16(符号化方式)の違い、UTF-8 の可変長バイト構造、JavaScript の文字列が UTF-16 ベースゆえに String.length が見た目の文字数と合わない理由、結合文字・絵文字・正規化(NFC/NFD)、そして文字化けの典型原因(エンコーディング不一致・MySQL の utf8 と utf8mb4・BOM・meta charset)と対策まで、Unicode 公式・RFC 3629・WHATWG を一次ソースにまとめます。

タイムゾーンと日付の正しい扱い方 - UTC・ISO 8601・夏時間の罠と JavaScript Date

タイムゾーンと日付の正しい扱い方 - UTC・ISO 8601・夏時間の罠と JavaScript Date

約9分

日付とタイムゾーンの扱いを実務目線で整理します。UTC とオフセットとIANAタイムゾーン(Asia/Tokyo)の違い、ISO 8601 / RFC 3339 の推奨フォーマット、Unixエポック、JavaScript の Date のハマりどころ(月が0始まり・日付のみ文字列はUTC解釈・getTimezoneOffsetの符号)、夏時間(DST)で生じる「存在しない時刻」と「重複する時刻」、そして「保存はUTC・表示時に変換・IANA識別子で扱う」などのベストプラクティスを、MDN・RFC 3339・IANA を一次ソースにまとめます。

REST API設計の基礎と冪等性 - HTTPメソッドの意味と Idempotency-Key

REST API設計の基礎と冪等性 - HTTPメソッドの意味と Idempotency-Key

約10分

REST API設計の基礎を、HTTPメソッドの意味(safe / idempotent)から整理します。リソース指向のURL設計、GET/POST/PUT/PATCH/DELETE の使い分けと冪等性の早見表、なぜ冪等性がリトライや二重課金防止に重要か、POST を安全にリトライする Idempotency-Key の仕組み、ステータスコードの使い分け、RFC 9457 のエラー形式まで、RFC 9110・MDN・Stripe を一次ソースにまとめます。