背景
自分のサイトに Base64 の変換ツールを置いています。
ブラウザで動くので、デコードには atob を使っています。
一方、サーバー側のスクリプトでは何も考えずに Buffer.from(s, 'base64') を使っていました。
同じ文字列を渡したら同じ結果になると思い込んでいたのですが、確かめてみたらだいぶ違いました…
結論
Node.js の Buffer.from(s, 'base64') は、ゆるく読みます。
- 標準形(
+/)と URL セーフ形(-_)を区別しない - Base64 に使わない文字(
*など)は黙って読み飛ばす -
=の後ろに文字が続いていても捨てる
ブラウザの atob は、URL セーフ形も不正な文字も InvalidCharacterError という例外にします。
入力が正しい Base64 かを確かめたい場面では、Buffer.from の結果だけを信じないほうがいいです。
Base64 は、バイト列を英数字と + / の64種類の文字で表す決まりだ。
3バイトを4文字にして、足りない分は末尾を = で埋める(パディング)。
URL セーフ形(Base64URL)は、URL で意味を持つ + / を - _ に置き換え、= を省いた形である。
試したこと
a?>~ という4文字をエンコードすると、標準形と URL セーフ形で見た目が変わります。
? > ~ を選んだのは、ちょうど + が出る並びだからです。
const s = 'a?>~';
console.log(Buffer.from(s).toString('base64')); // YT8+fg==
console.log(Buffer.from(s).toString('base64url')); // YT8-fg
標準形が YT8+fg==、URL セーフ形が YT8-fg になることがわかると思います。
次に、いくつかの入力を Buffer.from と atob の両方でデコードしました。
Node.js v24.11.1 で、atob は Node のグローバルにあるものを使っています。
for (const inp of ['YT8+fg==', 'YT8-fg', 'YT8*+fg==', 'YT8+fg==ZZ']) {
let viaAtob;
try { viaAtob = JSON.stringify(atob(inp)); } catch (e) { viaAtob = e.name; }
const viaBuffer = JSON.stringify(Buffer.from(inp, 'base64').toString('latin1'));
console.log(JSON.stringify(inp), 'Buffer=', viaBuffer, 'atob=', viaAtob);
}
inp がデコードしたい文字列で、latin1 で文字にしているのはバイトをそのまま1文字ずつ見るためです(atob の戻り値と同じ形に揃えています)。
結果は下記でした。
"YT8+fg==" Buffer= "a?>~" atob= "a?>~"
"YT8-fg" Buffer= "a?>~" atob= InvalidCharacterError
"YT8*+fg==" Buffer= "a?>~" atob= InvalidCharacterError
"YT8+fg==ZZ" Buffer= "a?>~" atob= InvalidCharacterError
Buffer.from は4つとも a?>~ を返しています。
* が混ざっていても、= の後ろに ZZ が付いていても、何も言わずに同じ値です。
atob は、正しい標準形の1行目以外を全部例外にしました。
どちらが困るのか
URL セーフ形をそのまま読めるのは、Buffer.from の便利なところです。
JWT(ログイン情報を URL セーフ形の Base64 で運ぶトークン)を手で読むときは、むしろ助かります。
困るのは、入力のチェックを Buffer.from に任せてしまったときです。
たとえば「送られてきた文字列が Base64 なら受け付ける」という処理で、Buffer.from が例外を出さないことを「正しい Base64 だった」と読み替えると、* 入りの壊れた文字列も通ってしまいます。
デコード結果が同じなので、ログを見ても気づけません。
逆に、ブラウザ側の私のツールは atob なので、そのままだと URL セーフ形を貼った人がエラーになります。
そこで、デコードの前に下記で標準形へ戻しています。
// URLセーフ表記を標準Base64へ戻す(-_→+/、空白を除き、パディングを補う)
function fromUrlSafe(b64) {
let s = b64.replace(/-/g, '+').replace(/_/g, '/').replace(/\s+/g, '');
while (s.length % 4 !== 0) s += '=';
return s;
}
console.log(atob(fromUrlSafe('YT8-fg'))); // a?>~
b64 は利用者が貼り付けた文字列です。
- と _ を戻して4の倍数まで = を足すと、atob が読める形になります。
* のような文字はここでは消さないので、atob がちゃんと例外にしてくれます。
https://hashitosystem.com/tools/base64/
検証したいときの書き方
Node 側で厳しく見たいときは、デコードの前に文字種と長さを確かめます。
function isStrictBase64(s) {
return /^[A-Za-z0-9+/]*={0,2}$/.test(s) && s.length % 4 === 0;
}
console.log(isStrictBase64('YT8+fg==')); // true
console.log(isStrictBase64('YT8*+fg==')); // false
console.log(isStrictBase64('YT8+fg==ZZ')); // false
s は検証したい文字列で、標準形だけを通す前提の正規表現です。
URL セーフ形も受けたいなら、文字クラスに -_ を足して長さの条件を外します。
Buffer.from(s, 'base64') は「読めるところだけ読む」関数なので、検証には使わない。
入力を確かめたいときは、正規表現で形を見てからデコードする、と覚えておけば大丈夫です。
本記事はAI補助で執筆した、個人開発の紹介記事です。