きっかけ
JWT のデバッグといえば jwt.io — それはそう。ただ、本番トークンをブラウザのあの input に貼り付けるの、いつも少しドキドキしませんか?
公式ドキュメントには「デコードは全てブラウザ内で行われ、トークンは送信されない」と書いてあります。信じていいんですが、信じるしかない、というのがちょっと気持ち悪い。スクリプトが将来こっそり fetch('/collect', ...) を足したら、私たちは気づけません。
そこで、ビルド内容で「何も送信しない」ことが保証できる、完全ローカルの JWT デバッガを作りました。
作ったもの
JWT Debugger — https://sen.ltd/portfolio/jwt-debugger/
- Header / Payload / Signature を分解表示
- 時刻クレーム (
exp/iat/nbf/auth_time) を Unix / ISO / 相対時刻("5 分後に期限切れ" 等)で表示 - 期限切れ・未来開始のトークンは即警告
- UTF-8 payload 対応(日本語 claim が化けない)
- 日英 UI
vanilla JS + HTML + CSS、ゼロ依存、ビルド不要。ネットワーク呼び出し 0 件。fetch も XMLHttpRequest も使っていないので、「何も送信しない」は grep 1 発で確認できます。
「送信しない」をどう保証するか
コードを見れば fetch が存在しないことはすぐ分かるけど、ユーザーにそれを確認させるのは非現実的です。代わりに CSP header で禁止するのが現実的な保証方法:
<meta http-equiv="Content-Security-Policy" content="
default-src 'self';
connect-src 'none';
img-src 'self' data:;
">
connect-src 'none' により、このページから fetch / XMLHttpRequest / WebSocket / EventSource がブラウザレベルで全部禁止されます。JavaScript に悪意があっても通信できません。ソース改ざんの監査を依頼するより、この 1 行の方がはるかに強い保証。
(実際のデプロイでは meta タグ版を使っていますが、本番では HTTP response header で配信した方がより確実です)
JWT は Base64url であって Base64 ではない
JWT の各セグメントは Base64url エンコードです。普通の Base64 との違い:
-
+の代わりに- -
/の代わりに_ - 末尾の
=padding は省略可
URL に入れても escape が不要にする、という発想の派生版。デコードするときは、逆変換してから普通の atob に渡します。
export function base64UrlDecode(s) {
let b64 = s.replace(/-/g, '+').replace(/_/g, '/')
const pad = b64.length % 4
if (pad === 2) b64 += '=='
else if (pad === 3) b64 += '='
else if (pad === 1) throw new Error('invalid base64url length')
const binary = atob(b64)
const bytes = new Uint8Array(binary.length)
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i)
return new TextDecoder().decode(bytes)
}
ポイントは 最後の TextDecoder。atob の結果はバイト列ではなく「各文字が 1 バイトを表す擬似文字列」なので、Uint8Array に詰め直してから UTF-8 デコードが必要です。これをサボると日本語 claim が化けます。
// payload に { "name": "山田太郎" } が入っているトークンの場合
{ name: "山田太郎" } // TextDecoder を省略した場合(mojibake)
{ name: "山田太郎" } // TextDecoder あり(正しい)
ちなみに Padding の復元ロジック (pad === 1 で invalid) は RFC 7515 の参照実装に合わせています。長さが 4k+1 になる Base64url は存在しない(4 bit 単位でエンコードするので)。
時刻クレームの「相対時刻」が一番使う機能
JWT payload に含まれる時刻クレームは全部 Unix 秒(ミリ秒ではない。ここでハマる人が多い)。
-
exp: expires at -
iat: issued at -
nbf: not before -
auth_time: authentication time (OIDC)
1721462400 を見せられても「いつやねん」となるので、3 つの表現を並べます:
annotations[claim] = {
value: ts, // 1721462400
iso: new Date(ts * 1000).toISOString(), // 2024-07-20T08:00:00.000Z
delta: ts - nowSec, // -3600 (= 1時間前)
}
UI 側では delta を「1 時間前」「5 分後」みたいな相対文字列に変換します。デバッグ中に**一番知りたい情報は「このトークンはまだ生きているか」**なので、この delta がすべて。
期限切れと未来開始はそのまま警告に回します:
if (annotations.exp && annotations.exp.delta <= 0) {
warnings.push(`exp: expired ${-annotations.exp.delta}s ago`)
}
if (annotations.nbf && annotations.nbf.delta > 0) {
warnings.push(`nbf: not valid for another ${annotations.nbf.delta}s`)
}
署名検証はあえてしない
本ツールは 署名検証しません。理由:
- 検証には secret か public key が必要。secret をブラウザに入力させるのは本末転倒(まさに jwt.io を避けた理由)
- HS256 の検証にサーバ秘密鍵を貼るような運用を助長したくない
- 署名検証はサーバ側で行うべき。クライアントツールで OK を出してしまうと危険
このツールの用途は「内容を確認する」ことだけ。「このトークンは正しく署名されているか」はサーバの仕事です、という立場を明確にしています。
エラーハンドリングはセグメントごと
JWT のパースは複数段階で失敗しうるので、どこで失敗したかを明示したい:
return { ok: false, error: 'token must be a string' }
return { ok: false, error: `expected 3 segments, got ${parts.length}` }
return { ok: false, error: `header: ${e.message}` }
return { ok: false, error: `payload: ${e.message}` }
return { ok: false, error: `header JSON: ${e.message}` }
return { ok: false, error: `payload JSON: ${e.message}` }
Base64url デコードで落ちたのか、JSON パースで落ちたのか、Header か Payload かを全部区別。UI 側でユーザーに「ここが壊れてますよ」と言えるようにするため。
大抵のエラーは「コピペで末尾が欠けた」か「truncate された」のどちらか。セグメント数エラーで「got 2」と出れば、signature が落ちてるのが即分かります。
テスト
node --test で 13 ケース。
- 有効な HS256 / RS256 トークンのパース
- Base64url の
-_処理 - padding なし、padding 2 文字、padding 1 文字
- 日本語 payload の round-trip
- セグメント数エラー
- 不正な base64 文字
- 不正な JSON
-
exp/nbfの warning 判定
npm test
おわりに
SEN 合同会社の ポートフォリオシリーズ 100+ の 6 件目です。
- 📦 レポジトリ: https://github.com/sen-ltd/jwt-debugger
- 🌐 ライブデモ: https://sen.ltd/portfolio/jwt-debugger/
- 🏢 会社: https://sen.ltd/
セキュリティ上の指摘・改善案、歓迎です。
