JWTのpayloadをatobで読むと落ちる・文字化けする理由と直し方

JWT の中身を確認したくて、payload の部分を atob() に渡したところ例外が出た、あるいは日本語の名前が å±±ç°... のように化けた、という経験は多い。原因は 2 つで、どちらも JWT が使う Base64URL と、標準の Base64 との差から来る。

この記事では Node.js で両方を実際に起こし、直した関数を示す。なお、ここで扱うのはデコード(読むだけ)であり、署名の検証ではない。この点は最後に触れる。

仕様で押さえる点

JWT は RFC 7519、署名付きの形式 JWS は RFC 7515 で定義されている。ヘッダ、payload、署名の 3 つを . でつなぎ、各部分は Base64URL でエンコードする。Base64URL は RFC 4648 の URL 安全な表記で、標準の Base64 から次の点が変わる。

項目 標準 Base64 Base64URL
62 番目の文字 + -
63 番目の文字 / _
末尾の = あり JWS では付けない

atob() は標準 Base64 用なので、- や _ を含む入力は受け付けない。また JWT の payload は JSON を UTF-8 のバイト列にしてからエンコードしたものなので、atob() が返す文字列をそのまま使うと、2 バイト以上の文字が壊れる。

実際に試す

前提は Node.js(ここでは Windows 11 上の v24.11.1)。atob と TextDecoder はブラウザにもあるので、同じコードがブラウザでも動く。次を decode-jwt.mjs として保存する。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// decode-jwt.mjs
export function decodeSegment(seg) {
const b64 = seg.replace(/-/g, '+').replace(/_/g, '/');
const padded = b64 + '='.repeat((4 - (b64.length % 4)) % 4);
const bin = atob(padded);
const bytes = Uint8Array.from(bin, (c) => c.charCodeAt(0));
return new TextDecoder('utf-8', { fatal: true }).decode(bytes);
}

export function decodeJwt(token) {
const parts = token.split('.');
if (parts.length !== 3) throw new Error('segments=' + parts.length);
return {
header: JSON.parse(decodeSegment(parts[0])),
payload: JSON.parse(decodeSegment(parts[1])),
};
}

1. -と_を含むと atob は例外になる

payload に日本語と ?>>> を入れたトークンの一部を作り、素の atob に渡す。?>>> を入れたのは、Base64URL で - や _ が出る典型的なバイト列を意図的に作るためだ。

1
2
3
4
const seg = Buffer.from(JSON.stringify({ sub: '1234567890', name: '山田太郎', note: '?>>>' }))
.toString('base64url');
console.log(seg);
try { atob(seg); } catch (e) { console.log(e.name); }
1
2
eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IuWxseeUsOWkqumDjiIsIm5vdGUiOiI_Pj4-In0
InvalidCharacterError

セグメントの中に _ と - があり、そのせいで InvalidCharacterError になった。- を +、_ を / に置き換えれば atob は通る。

2. パディングは足さなくても通る

= を補う処理は、よく見るコードに入っているが、実測では必須ではなかった。- と _ を置換したあとなら、= なしでも atob は復号した。

1
2
3
console.log(atob('eyJhIjoxfQ').length);     // 7  ('=' なし)
console.log(atob('eyJhIjoxfQ==').length); // 7 ('==' あり)
try { atob('eyJhIjoxfQ='); } catch (e) { console.log(e.name); } // 数が合わない '=' は例外
1
2
3
7
7
InvalidCharacterError

長さが 4 の倍数でないときの = 補完は、他の環境(古いライブラリや別言語)で必要になることがあるので、上の関数では補っている。ただし補う数を間違えると逆に例外になる点には注意が要る。長さを 4 で割った余りが 1 になる入力は、Base64 としてあり得ず、どのみち例外になる。

1
2
import { decodeSegment } from './decode-jwt.mjs';
try { decodeSegment('abcde'); } catch (e) { console.log(e.name); }
1
InvalidCharacterError

3. 日本語が化けるのは UTF-8 として読んでいないから

置換だけして atob の戻り値をそのまま使うと、例外は出ないが日本語が化ける。

1
2
const b64 = seg.replace(/-/g, '+').replace(/_/g, '/');
console.log(atob(b64 + '='.repeat((4 - b64.length % 4) % 4)));
1
{"sub":"1234567890","name":"å±±ç°å¤ªé","note":"?>>>"}

atob はバイト列を 1 バイト 1 文字の文字列にして返すだけで、UTF-8 の解釈はしない。上の decodeSegment のように Uint8Array に直して TextDecoder に渡すと、"name":"山田太郎" が元通りになる。fatal: true を付けているので、UTF-8 として不正なバイト列(実測では 0xFF 0xFE)は黙って置換文字にならず、TypeError で止まる。

4. 通しで動かす

1
2
3
4
5
6
7
8
9
10
11
12
import { createHmac } from 'node:crypto';
import { decodeJwt } from './decode-jwt.mjs';

const enc = (o) => Buffer.from(JSON.stringify(o)).toString('base64url');
const h = enc({ alg: 'HS256', typ: 'JWT' });
const p = enc({ sub: '1234567890', name: '山田太郎', note: '?>>>' });
const sig = createHmac('sha256', 'k').update(h + '.' + p).digest('base64url');

console.log(decodeJwt(h + '.' + p + '.' + sig));
for (const bad of ['a.b', h + '.' + p + '.' + sig + '.x']) {
try { decodeJwt(bad); } catch (e) { console.log(e.message); }
}
1
2
3
4
5
6
{
header: { alg: 'HS256', typ: 'JWT' },
payload: { sub: '1234567890', name: '山田太郎', note: '?>>>' }
}
segments=2
segments=4

Node.js だけなら Buffer.from(seg, 'base64url').toString() の 1 行でも同じ結果になった。ブラウザでは Buffer が使えないので、上の関数を使う。

読めることと信頼できることは別

ここまでのコードは中身を読むだけで、署名は確かめていない。ペイロードは誰でも読めるし、書き換えた JWT も同じ手順でデコードできてしまう。権限判断に使うなら、署名、exp、iss、aud をライブラリで検証したうえで読むこと。検証の流れは、同じブログの SharePoint の JWT 検証チェーン の記事で扱っている。

手元でとりあえず中身を見たいだけなら、ブラウザ内で完結する JWT デコーダー(ハシトシステム) に貼ればよい。ただし本番の有効なトークンを共有端末や他人の画面に出すのは避けること。

参考

  • RFC 7519 (JSON Web Token) / RFC 7515 (JSON Web Signature)
  • RFC 4648 (Base64URL の定義)
  • WHATWG Encoding Standard(TextDecoder の fatal オプション)
  • Node.js ドキュメント Buffer(base64url エンコーディング)