背景
自分のツールサイトに、JWT の中身をブラウザだけで読むページを置いています。
https://hashitosystem.com/tools/jwt/
ログインまわりの不具合を調べるとき、トークンに何が入っているかを見たくて作りました。
最初は atob(token.split('.')[1]) の1行で済むと思っていたのですが、日本語の名前を入れたトークンで試したら、化けるものと例外で落ちるものが出ました。
どちらも同じ原因の裏表だったので、残しておきます。
JWT の形
JWT(JSON Web Token)は、ログイン情報などを JSON にして持ち運ぶためのトークンだ。
ヘッダー.ペイロード.署名 の3つを . でつないだ文字列で、ペイロードに sub(ユーザーID)や name、iat(発行日時)などが入ります。
仕様は RFC 7519 です。
https://www.rfc-editor.org/rfc/rfc7519
各部分の符号化は、普通の Base64 ではなく Base64URL だ。
Base64 は、バイト列を英数字と + / の64文字で表す方式で、長さを4の倍数にそろえるために末尾に = を足します。
Base64URL はそれを URL に載せやすくした変種で、+ の代わりに -、/ の代わりに _ を使い、末尾の = は省きます。
JWT の元になっている RFC 7515 の2章に、この定義があります。
https://www.rfc-editor.org/rfc/rfc7515#section-2
atob だけで読んでみる
下記は Node.js でペイロードを作って、atob で読み戻してみたものです。
Buffer.from(...).toString('base64url') が、JWT と同じ Base64URL でペイロード部分を作る処理になっています。
// jwt-atob.js : JWT のペイロード部分を作って atob で読む
for (const name of ['中村', '小林']) {
const seg = Buffer.from(JSON.stringify({ sub: '42', name, iat: 1700000000 })).toString('base64url');
console.log(seg);
try {
console.log(atob(seg));
} catch (e) {
console.log(e.name);
}
}
Node.js v24.11.1 での結果が下記です。
eyJzdWIiOiI0MiIsIm5hbWUiOiLkuK3mnZEiLCJpYXQiOjE3MDAwMDAwMDB9
{"sub":"42","name":"䏿","iat":1700000000}
eyJzdWIiOiI0MiIsIm5hbWUiOiLlsI_mnpciLCJpYXQiOjE3MDAwMDAwMDB9
InvalidCharacterError
中村さんのほうは読めたものの、名前が 䏿 のように化けています(端末に出ない制御文字も混じっています)。
小林さんのほうは、2行目の途中に _ が入っていて、atob が例外を投げました。
_ が出るかどうかは中身のバイト列しだいなので、「テストでは通ったのに本番の誰かのトークンで落ちる」が起きます…。
2つの原因
1つ目は、atob が Base64 用で、Base64URL の - と _ を知らないことだ。
atob が使っている「forgiving-base64 decode」の定義は、使える文字を英数字と + / に限っています。
https://infra.spec.whatwg.org/#forgiving-base64-decode
ちなみに末尾の = が無いのは許してくれるので(atob('YQ') は a になりました)、パディングより先に文字の置き換えが要ります。
2つ目は、atob の戻り値が「1文字=1バイト」の文字列であることだ。
JSON の中の日本語は UTF-8 で、1文字が3バイトになっています。
atob はその3バイトを3文字としてそのまま返すので、UTF-8 として組み立て直さないと化けます。
正しく読む
下記が、ツールで使っている読み方と同じ手順です。
引数の str は、JWT を . で分けたうちの1つ(ヘッダーかペイロード)です。
// Base64URL の1部分 → UTF-8 の文字列
function base64UrlDecode(str) {
let s = str.replace(/-/g, '+').replace(/_/g, '/');
const pad = s.length % 4;
if (pad === 2) s += '==';
else if (pad === 3) s += '=';
else if (pad === 1) throw new Error('長さが不正です');
const bin = atob(s);
const bytes = Uint8Array.from(bin, (c) => c.charCodeAt(0));
return new TextDecoder('utf-8').decode(bytes);
}
const seg = 'eyJzdWIiOiI0MiIsIm5hbWUiOiLlsI_mnpciLCJpYXQiOjE3MDAwMDAwMDB9';
console.log(JSON.parse(base64UrlDecode(seg)));
- と _ を + と / に戻し、= を補ってから atob しています。
atob の結果は1文字ずつ charCodeAt で数値にして Uint8Array に詰め、TextDecoder で UTF-8 として読みます。
実行結果が下記です。
{ sub: '42', name: '小林', iat: 1700000000 }
例外だった小林さんのトークンも、化けずに 小林 で戻りました。
= を補う部分は atob だけなら無くても動くのですが、長さが4で割って1余る文字列は Base64 として成り立たないので、そこで弾く意味で残しています。
Node.js だけで使うなら Buffer.from(str, 'base64url').toString('utf8') の1行でも同じ結果になります。
ブラウザでも同じコードで動くように、ツールでは atob と TextDecoder の組み合わせにしています。
注意: 読めることと、信じてよいことは別
ここでやっているのは、中身を見るための復号だけで、署名の検証はしていません。
ペイロードは誰でも書き換えて符号化し直せるので、アプリ側でユーザーの権限などを判断するときは、必ずサーバー側で署名を検証したうえで使ってください。
JWT を手で読むときは、atob の1行で済ませず、- と _ の置き換えと UTF-8 への組み立て直しの2つを必ず入れてください。
本記事はAI補助で執筆した、個人開発の紹介記事です。