Basic認証のbtoaが日本語で落ちる理由とRFC 7617のUTF-8

Authorization: Basic ... の値は「ユーザー名:パスワード」を Base64 にしただけの文字列なので、JavaScript なら btoa(user + ':' + pass) の1行で作れる、と書かれていることが多い。ところがこの1行は、パスワードに日本語が入った時点で例外を投げる。£ のような Latin-1 の文字なら例外は出ないが、今度はサーバーが期待する値と一致しない。

この記事では、ハシトシステムのBasic認証ヘッダー生成・デコードツールが実装している変換を読みながら、

  • btoa が日本語で落ちる理由(HTML Standard の定義)
  • RFC 7617 が決めている文字コードの扱い(charset="UTF-8" と NFC)
  • ユーザー名にコロンを入れると、どこで分割されるか

を、手元で動かしたコードで確かめる。確認した環境は Windows 11 / Node.js v24.11.1 / curl 8.21.0 である。

Basic 認証の値の作り方

Basic 認証の現行仕様は RFC 7617(The ‘Basic’ HTTP Authentication Scheme、2015年9月、RFC 2617 を廃止)である。section 2 は、クライアントが値を作る手順を次の4段階で書いている。

  1. ユーザーからユーザー名(user-id)とパスワードを受け取る
  2. ユーザー名、コロン1文字、パスワードの順につなげて user-pass を作る
  3. user-pass をバイト列(octet sequence)にする
  4. そのバイト列を Base64 にする

RFC 自身が挙げる例は、ユーザー名 Aladdin・パスワード open sesame で、ヘッダーは Authorization: Basic QWxhZGRpbjpvcGVuIHNlc2FtZQ== になる。

問題は手順3である。RFC 7617 は、もとの定義(RFC 2617 以前)が文字列をバイト列にする方式を決めていなかったこと、実装の多くが ISO-8859-1 などのロケール依存の文字コードか UTF-8 を選んだことを認めたうえで、後方互換のために既定の文字コードを今も決めていない(US-ASCII と互換であることだけを求めている)。ASCII だけのユーザー名とパスワードなら、どの文字コードでも同じバイト列になるので問題が表に出ない。

btoa は「1文字=1バイト」の文字列しか受け取らない

btoa は HTML Standard の section 8.3(Base64 utility methods)で定義されている。仕様の手順は、入力に U+00FF より大きいコードポイントの文字が1つでもあれば InvalidCharacterError の DOMException を投げる、そうでなければ n 番目の文字のコードポイントをそのまま n 番目のバイトとみなして Base64 にする、というものである。

つまり btoa は「文字列」を受け取る関数ではなく、「1文字に1バイトを詰めた文字列」を受け取る関数である。ここから2つの症状が出る。

  • パ(U+30D1)のような U+00FF を超える文字があると、例外になる
  • £(U+00A3)のように U+00FF 以下の文字は、例外にならず Latin-1 の1バイト(0xA3)としてエンコードされる。UTF-8 なら C2 A3 の2バイトになるので、結果が変わる

2つめは気づきにくい。RFC 7617 の section 2.1 は、ユーザー名 test・パスワード 123£ を UTF-8 でエンコードした例として dGVzdDoxMjPCow== を載せている。btoa('test:123£') は例外を出さずに別の値を返すので、エラーにならないまま認証だけが失敗する。

RFC 7617 の charset=”UTF-8”

文字コードを決めていない代わりに、RFC 7617 はサーバーが期待する文字コードを伝える手段を用意した。それが section 2.1 の charset パラメータで、401 応答のチャレンジに付ける。

1
WWW-Authenticate: Basic realm="foo", charset="UTF-8"

仕様の要点は次のとおり。

項目 内容
指定できる値 UTF-8 だけ(大文字小文字は区別しない)。それ以外は将来のために予約
意味 サーバーは、文字列が Unicode 正規化形式 C(NFC)に変換され、UTF-8 でバイト列にされることを期待する
性質 「purely advisory」(あくまで助言)
付ける場所 チャレンジ(WWW-Authenticate)だけ。資格情報側の構文は token68 で拡張できないため

NFC の指定は見落としやすい。同じ「ガ」でも、合成済みの1文字(U+30AC)と、「カ」+濁点の結合文字(U+30AB U+3099)の2文字では、UTF-8 のバイト列が違う。macOS のファイル名や一部の入力経路では後者が混ざるので、正規化しないまま比較すると、見た目が同じパスワードでも一致しない。

ユーザー名のコロンは最初の区切りになる

RFC 7617 section 2 は、ユーザー名とパスワードに制御文字を含めてはならない(MUST NOT)としたうえで、コロンを含むユーザー名は無効だと書いている。user-pass の中の最初のコロンが区切りとして扱われ、それ以降はすべてパスワードになるからである。逆に言えば、パスワードにはコロンを含めてよい。

RFC は、多くのユーザーエージェントがこの点を検査せずに user-pass を作ること、その場合は受け手がユーザー名の一部をパスワードとして扱うことも注記している。curl の -u オプションのドキュメント(docs/cmdline-opts/user.md)も、ユーザー名とパスワードを最初のコロンで分割するので、ユーザー名にはコロンを使えない(パスワードには使える)としている。

実装:TextEncoder でバイト列にしてから btoa に渡す

ハシトシステムのBasic認証ヘッダー生成・デコードツールは、外部ライブラリを使わずにページ内の JavaScript で変換している。エンコードとデコードは次の2関数である(ツールのソースをそのまま引用)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
/* UTF-8 のまま Base64 にする(btoa は Latin-1 しか扱えないため、
一度バイト列にしてから1バイト1文字の文字列に直して渡す)。 */
function b64encodeUtf8(str){
var bytes = new TextEncoder().encode(str);
var bin = '';
for(var i=0;i<bytes.length;i++){ bin += String.fromCharCode(bytes[i]); }
return btoa(bin);
}
function b64decodeUtf8(b64){
var bin = atob(b64);
var bytes = new Uint8Array(bin.length);
for(var i=0;i<bin.length;i++){ bytes[i] = bin.charCodeAt(i); }
return new TextDecoder().decode(bytes);
}

TextEncoder は常に UTF-8 でエンコードするので、まず UTF-8 のバイト列を作る。そのバイトを String.fromCharCode で1バイト1文字の文字列に詰め直せば、すべての文字が U+00FF 以下になり、btoa の前提を満たす。デコードはその逆で、atob が返す1バイト1文字の文字列を Uint8Array に戻し、TextDecoder で UTF-8 として読む。

ツールは生成の前に、ユーザー名にコロンが含まれていないかも検査し、含まれていれば値を作らずに注意を出す。デコード側は、復号した文字列を最初のコロンで分けてユーザー名とパスワードを表示する。RFC 7617 の規則をそのまま画面の動作にしている。

実際に試す

1. btoa と UTF-8 経由の違いを見る

次のスクリプトを basicauth-demo.mjs として保存し、node basicauth-demo.mjs で実行する。Node.js 16 以降なら btoa・atob・TextEncoder・TextDecoder がグローバルにある。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
function b64encodeUtf8(str) {
const bytes = new TextEncoder().encode(str);
let bin = '';
for (const b of bytes) bin += String.fromCharCode(b);
return btoa(bin);
}
function b64decodeUtf8(b64) {
const bin = atob(b64);
const bytes = Uint8Array.from(bin, (c) => c.charCodeAt(0));
return new TextDecoder().decode(bytes);
}

console.log('1) RFC 7617 の例');
console.log(' Aladdin :', btoa('Aladdin:open sesame'));
console.log(' test:123£ UTF-8 :', b64encodeUtf8('test:123£'));
console.log(' test:123£ btoa直接:', btoa('test:123£'));

console.log('2) 日本語パスワードを btoa に直接渡す');
try {
console.log(' ', btoa('user:パスワード'));
} catch (e) {
console.log(' ', e.name, '-', e.message);
}
console.log(' UTF-8 経由:', b64encodeUtf8('user:パスワード'));
console.log(' Buffer :', Buffer.from('user:パスワード', 'utf8').toString('base64'));

console.log('3) NFC と NFD で Base64 が変わる');
const nfc = 'user:ガ'.normalize('NFC');
const nfd = 'user:ガ'.normalize('NFD');
console.log(' NFC', nfc.length, '文字', b64encodeUtf8(nfc));
console.log(' NFD', nfd.length, '文字', b64encodeUtf8(nfd));

console.log('4) ユーザー名にコロンがあると、どこで割れるか');
const token = b64encodeUtf8('team:alice' + ':' + 'p@ss:word');
const text = b64decodeUtf8(token);
const i = text.indexOf(':');
console.log(' token :', token);
console.log(' user-id :', JSON.stringify(text.slice(0, i)));
console.log(' password:', JSON.stringify(text.slice(i + 1)));

Node.js v24.11.1 での出力は次のとおり。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
1) RFC 7617 の例
Aladdin : QWxhZGRpbjpvcGVuIHNlc2FtZQ==
test:123£ UTF-8 : dGVzdDoxMjPCow==
test:123£ btoa直接: dGVzdDoxMjOj
2) 日本語パスワードを btoa に直接渡す
InvalidCharacterError - Invalid character
UTF-8 経由: dXNlcjrjg5Hjgrnjg6/jg7zjg4k=
Buffer : dXNlcjrjg5Hjgrnjg6/jg7zjg4k=
3) NFC と NFD で Base64 が変わる
NFC 6 文字 dXNlcjrjgqw=
NFD 7 文字 dXNlcjrjgqvjgpk=
4) ユーザー名にコロンがあると、どこで割れるか
token : dGVhbTphbGljZTpwQHNzOndvcmQ=
user-id : "team"
password: "alice:p@ss:word"

読み取れることは4つある。

  • 1 の Aladdin と UTF-8 版の test:123£ は、RFC 7617 に載っている値と一致する。btoa に直接渡した test:123£ は dGVzdDoxMjOj になり、例外は出ないが値が違う
  • 2 では btoa が InvalidCharacterError を投げる。UTF-8 経由の関数と Node.js の Buffer は同じ値を返す
  • 3 では、見た目が同じ「ガ」でも NFC と NFD で文字数も Base64 も違う
  • 4 では、ユーザー名 team:alice の最初のコロンで割れて、ユーザー名が team、パスワードが alice:p@ss:word になる

2. 最小のサーバーを立てて curl で確かめる

受け手の側も書いておく。次を basicauth-server.mjs として保存し、node basicauth-server.mjs で起動する。127.0.0.1 だけで待ち受ける確認用のサーバーである。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
import http from 'node:http';
import { timingSafeEqual } from 'node:crypto';

const USER = 'alice';
const PASS = 'p@ss:word'; // パスワードにはコロンを含めてよい

function parseBasic(header) {
const m = /^Basic\s+([A-Za-z0-9+/]+=*)\s*$/i.exec(header || '');
if (!m) return null;
const text = Buffer.from(m[1], 'base64').toString('utf8').normalize('NFC');
const i = text.indexOf(':'); // 最初のコロンで割る(RFC 7617 section 2)
if (i === -1) return null;
return { user: text.slice(0, i), pass: text.slice(i + 1) };
}

function same(a, b) {
const x = Buffer.from(a, 'utf8');
const y = Buffer.from(b, 'utf8');
return x.length === y.length && timingSafeEqual(x, y);
}

const server = http.createServer((req, res) => {
const cred = parseBasic(req.headers.authorization);
if (cred && same(cred.user, USER) && same(cred.pass, PASS)) {
res.end(`ok user=${cred.user}\n`);
return;
}
res.writeHead(401, { 'WWW-Authenticate': 'Basic realm="demo", charset="UTF-8"' });
res.end(`unauthorized parsed=${JSON.stringify(cred)}\n`);
});

server.listen(8787, '127.0.0.1', () => console.log('listening on http://127.0.0.1:8787/'));

別のターミナルから curl で叩く(Git Bash などの POSIX シェルを想定)。

1
2
3
4
5
6
7
8
# 資格情報なし: 401 と charset 付きのチャレンジが返る
curl -s -i http://127.0.0.1:8787/

# -u はユーザー名とパスワードを最初のコロンで分ける
curl -s -v -u 'alice:p@ss:word' http://127.0.0.1:8787/ 2>&1 | grep -i -E 'authorization|^ok'

# ヘッダーを直接書いても同じ
curl -s -H 'Authorization: Basic YWxpY2U6cEBzczp3b3Jk' http://127.0.0.1:8787/

curl 8.21.0 での結果は次のとおり(日付などのヘッダーは省略)。

1
2
3
4
5
6
7
8
9
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Basic realm="demo", charset="UTF-8"

unauthorized parsed=null

> Authorization: Basic YWxpY2U6cEBzczp3b3Jk
ok user=alice

ok user=alice

-u 'alice:p@ss:word' は最初のコロンで分割されるので、パスワードの中のコロンはそのまま残り、認証が通る。-u が送った値と、手で書いたヘッダーの値は同じ YWxpY2U6cEBzczp3b3Jk である。

サーバー側で気をつけた点は3つある。

  • 分割は indexOf(':')(最初のコロン)で行う。split(':') で2要素を取ると、コロンを含むパスワードが切れる
  • 復号した文字列を normalize('NFC') してから比較する。RFC 7617 が charset="UTF-8" に結び付けているのが NFC だからである
  • 比較は timingSafeEqual で行う。長さが違うと例外になるので、先に長さを比べている

Base64 は暗号化ではない

最後に前提を確認しておく。RFC 7617 の section 4(Security Considerations)は、Basic 認証ではパスワードが平文のままネットワークを流れること、そのため HTTPS などの補強なしに重要な情報の保護に使うべきではない(SHOULD NOT)ことを明記している。デコードツールで QWxhZGRpbjpvcGVuIHNlc2FtZQ== を貼れば、誰でもすぐに Aladdin と open sesame に戻せる。

curl のドキュメントも、-u に渡した値はプロセス一覧から隠そうとするものの効果は限られるとして、資格情報はコマンドラインに平文で書かずファイルなどから読むよう勧めている。ハシトシステムのツールは入力をブラウザ内だけで処理し、サーバーには送らない作りになっているが、本番の資格情報を扱うときは、検証用の値に置き換えてから試すのが安全である。

まとめ

  • btoa は U+00FF を超える文字で InvalidCharacterError を投げ、それ以下の文字は Latin-1 の1バイトとして扱う。日本語では例外、£ では値の不一致として表に出る
  • RFC 7617 は既定の文字コードを決めていない。サーバーは WWW-Authenticate に charset="UTF-8" を付けて、NFC+UTF-8 を期待していると伝えられる
  • ユーザー名のコロンは使えない。最初のコロンが区切りで、パスワードにはコロンを含めてよい。受け手は最初のコロンで分割する

ブラウザ上で値を作ったりデコードしたりするなら、Basic認証ヘッダー生成・デコードツールで curl・fetch 用のスニペットまで出力できる。文字列の正規化そのものを確かめたいときは、同じハシトシステムのUnicode正規化フォーム比較ツールで NFC と NFD の違いを見られる。

参考