本番のJWTをデコードサイトに貼りかけて手が止まった。だから送信しないJWTデコーダを自作した
デバッグ中にJWTのexpを確認したくて外部サイトに貼る直前で手が止まった。トークンを送信しないデコーダを自作した話と、Base64URL・alg:none・HS256とRS256の違いなど実装で整理したJWTの基礎。
APIが突然401を返し始めて、原因の切り分けをしていた。トークンが期限切れなのか、そもそも中身がおかしいのか。手元にあるのはアクセストークンの長い文字列だけ。中のexp(有効期限)を見れば一発でわかる。
で、いつもの癖でJWTデコードサイトを開いて、トークンをコピーして、貼り付ける直前で手が止まった。
これ、本番のアクセストークンだ。
JWTは「見るだけの文字列」ではなく、認証情報そのもの
JWTの中身を確認したいだけなのに何を大げさな、と思うかもしれない。でもJWTは、それ自体が「私はログイン済みのこのユーザーです」を証明する認証情報だ。パスワードをテキストボックスに貼るのと本質的に変わらない。もしそのトークンがどこかに送信・記録されて漏れたら、有効期限が切れるまでの間、第三者がそのユーザーとしてAPIを叩ける。
有名どころのデコードサイトは「処理はクライアントサイドで完結します」と明言している。それは多分本当だと思う。ただ、貼り付けるその瞬間に、そのページのJavaScriptが本当に何も送信していないことを自分は確認していない。アナリティクスやエラートラッカーが入力値を拾わない保証も、自分では持っていない。「多分大丈夫」で本番のトークンを外部に渡すのは、セキュリティの判断として筋が悪い。
このとき結局どうしたかというと、ターミナルでデコードした。JWTのペイロードはただのBase64URLなので、手元で展開できる。
echo 'eyJzdWIiOi...(2番目の部分)' | base64 -d
# → パディングの関係でエラーになることがあるのが地味に面倒
ただ、これが毎回微妙に面倒くさい。Base64URLは標準のBase64と文字が違うのでbase64 -dが素直に通らないことがあるし、出てきたexp: 1752710400を見ても、それが「いつ」なのか即答できない。デバッグのたびにこの小さな摩擦を踏むのが嫌になって、自分のサイトに「送信しないことをコードで保証できるJWTデコーダ」を作ることにした。それが JWTデコード・検証ツール だ。
「送信しない」は、自分で書いたコードなら保証できる
外部サイトの「送信しません」は信じるしかないが、自分で書いたツールなら話が違う。実装で担保したのは次の3点。
- fetch / XMLHttpRequest を一切書かない。 デコードも署名検証も、ページ内のJavaScriptだけで完結する。DevToolsのNetworkタブを開いたままトークンを貼れば、リクエストが1本も飛ばないことを誰でも確認できる
- localStorageにも保存しない。 ページを閉じたらトークンもシークレットも消える。「便利のために履歴を残す」はこの種のツールでは悪手だと判断した
- シークレット入力欄は
type="password"。 画面共有中に共有鍵が映る事故を防ぐ。必要なときだけチェックボックスで表示に切り替えられる
作ってみて思ったのは、JWTのデコード自体は本当に小さな処理だということ。外部にトークンを渡してまでやるような処理では、そもそもなかった。
作る過程で整理したJWTの基礎
実装してみると、なんとなく知っていたつもりの仕様を正確に理解していないことが次々に露呈した。デバッグでJWTを触る人なら知っておいて損がないものを、実装者目線でまとめておく。
Base64URLはBase64ではない
JWTの各部分はBase64「URL」エンコードで、標準のBase64と2点違う。+と/の代わりに-と_を使うこと、そして末尾のパディング=を省略すること。JWTはURLやHTTPヘッダに載せる前提の形式なので、URLで特別な意味を持つ文字を避けている。
ブラウザのatob()は標準のBase64しか受け付けないので、デコード前に変換とパディングの復元が要る。
function b64urlToBytes(s) {
let t = s.replace(/-/g, '+').replace(/_/g, '/');
const rem = t.length % 4;
if (rem === 1) throw new Error('Base64URLとして長さが不正です');
if (rem) t += '='.repeat(4 - rem);
const bin = atob(t);
// ... Uint8Arrayに詰める
}
さっきのbase64 -dが時々失敗していた理由もこれだった。ちなみにlength % 4 === 1になるBase64URL文字列は存在しないので、その場合は変換せずエラーにしている。トークンのコピペ欠けはだいたいここで検出できる。
デコードできる=正しいトークン、ではない
ここが一番大事で、一番誤解されやすい。JWTのヘッダーとペイロードは暗号化されていない。ただのBase64URLなので、誰でも読めるし、誰でも好きな中身のJWTを作れる。「デコードしたら正しそうなユーザーIDが入っていた」ことは、そのトークンが本物である証明に一切ならない。
本物かどうか(サーバーが発行したまま改ざんされていないか)を確かめる手段が3番目の部分、署名だ。自作ツールでは、デコード直後は必ず「署名は未検証です」というバナーを出すようにした。検証していない状態を「デコード成功」と表示すると、緑のチェックマークひとつで安全だと誤読されかねないからだ。
alg: “none” という歴史的な地雷
JWTのヘッダーにはalg(署名アルゴリズム)が書いてあり、仕様上は"none"=署名なしも合法だ。そして2015年、複数のJWTライブラリが「攻撃者がヘッダーのalgをnoneに書き換えたトークン」を署名検証なしで受け入れてしまう問題が広く知られることになった(Auth0のTim McLeanによる報告が有名)。署名を消してalg: noneにすれば改ざんし放題、という話で、JWTの脆弱性の代名詞みたいになっている。
現行のまともなライブラリは対策済みだが、「ヘッダーに書いてあるalgを無条件に信用してはいけない」という教訓は今も生きている。自作ツールでもalg: noneのトークンには専用の警告バナーを出すようにした。デバッグ中に手元のトークンがnoneだったら、それは検証環境の設定ミスか、もっと悪い何かだ。
HS256とRS256の違いは「鍵を共有するかどうか」
algでよく見るのはこの2つ。
- HS256(HMAC + SHA-256): 共有鍵方式。署名の作成と検証に同じシークレットを使う。つまり検証できる側は署名も作れる。発行者と検証者が同一(自前のAPIサーバーなど)ならこれで足りる
- RS256(RSA + SHA-256): 公開鍵方式。秘密鍵で署名し、公開鍵で検証する。検証者は公開鍵しか持たないので、トークンを偽造できない。Auth0やFirebaseのようなIDプロバイダのトークンはだいたいこちら
ついでに言うと、2015年の報告にはもうひとつ「鍵の混同攻撃」も含まれていた。algをRS256からHS256に書き換えると、実装によっては公開鍵(=誰でも知っている値)をHMACの共有鍵として検証に使ってしまい、偽造トークンが通る。「algはヘッダーではなくサーバー側の設定で固定する」が鉄則になったのは、この2つの事件があってこそだ。
署名検証はブラウザだけでできる(Web Crypto API)
作る前は「署名検証はさすがにサーバーが要るのでは」と思っていたが、Web Crypto API(crypto.subtle)で普通にできた。HMAC検証はこれだけだ。
const key = await crypto.subtle.importKey(
'raw', encoder.encode(secret),
{ name: 'HMAC', hash: 'SHA-256' }, false, ['verify']
);
const data = encoder.encode(headerB64 + '.' + payloadB64);
const ok = await crypto.subtle.verify('HMAC', key, sigBytes, data);
署名対象は「デコード後のJSON」ではなく「Base64URL文字列のままヘッダー.ペイロードと連結したもの」である点だけ注意。最初ここを勘違いして、正しいシークレットなのに検証が通らずしばらく悩んだ。
なお自作ツールの署名検証はHS256/HS384/HS512のみ対応にした。Web Crypto API自体はRSAの検証もできるので技術的にはRS256も可能なのだが、公開鍵(PEMやJWK)の取り込みが入って一気に複雑になる。RS256のトークンもデコードとクレーム確認はできるので、まずはHMACだけで公開した。
expの単位は「秒」
exp・iat・nbfはUNIX時間、つまり1970年1月1日UTCからの経過秒で入っている。JavaScriptのDateはミリ秒基準なので、比較するときはDate.now() / 1000かexp * 1000のどちらかに揃える必要がある。ここを間違えると「発行した瞬間に期限切れ」みたいな意味不明なバグになる(なった)。
自作ツールではexpを日本時間に変換した上で、「期限切れ(何分前に失効)」「有効(あと何分)」まで判定して表示するようにした。401デバッグで知りたいのは結局これなので、最初の課題はこれで解決した。UNIX時間の数値を単発で日時に変換したいだけなら、同じサイトの UNIX時間変換ツール の方が手早い。
それでも「本番トークンを貼らない」が基本
最後に、自分で作っておいて言うのも変だが、送信しないツールであっても本番の生きたトークンを気軽に貼る習慣はつけない方がいい。クリップボードに認証情報を載せること自体にリスクがあるし、「このツールは安全だから」の感覚が他のツールにも波及すると、冒頭の自分のように手が止まらなくなる。
JWTデコード・検証ツール にもその旨を明記した上で、検証用のサンプルトークン(シークレット付き)をワンクリックで試せるようにしてある。デコード結果のペイロードをさらに加工したければ JSON整形ツール もどうぞ。次に401で悩んだときの切り分けが、30秒で終わるようになった。