JWT 実装で 2026 年も踏まれている 5 つの落とし穴
JWT は仕様自体は古い(RFC 7519 は 2015 年)にも関わらず、検証ロジックを誤って書く実装が今も発生し続けています。本記事では、コードレビューや脆弱性レポートで繰り返し目にする 5 つのパターンを、攻撃シナリオと対策の両面から整理します。
1. alg=none を許容する
最も古典的かつ最も致命的。JWT には「署名なし」を表す alg: "none" が仕様上存在します。
{
"alg": "none",
"typ": "JWT"
}.{
"sub": "admin",
"exp": 9999999999
}. ピリオドの後ろが空(署名なし)で送られたとき、ライブラリの一部は「alg=none だから検証スキップして OK」とパスを通してしまう。これは 2015 年に大量のライブラリで発覚した CVE 群(CVE-2015-9235 ほか)の典型形ですが、今でも以下のパターンで再発します。
- 「
algをヘッダから読んでverify(token, key, algorithms=[alg])のように動的に渡す」実装。alg=noneが来るとalgorithms=['none']で検証成功扱い verify関数のオプション省略時のデフォルトがnone許容になっているライブラリ
対策
- 常に許可するアルゴリズムを定数で固定する:
algorithms=["RS256"]のようにホワイトリスト - ヘッダの
algを読んでそれをライブラリに渡してはいけない noneを「使えない」状態にしておくフラグがあるライブラリは ON にする
2. 公開鍵を秘密鍵として使われる(HS256 ↔ RS256 混同)
RS256(公開鍵暗号)で運用しているサービスに対して、alg を HS256 に変えたトークンを送る攻撃。
Header: {"alg": "HS256"}
Payload: {"sub": "admin"}
Signature: HMAC-SHA256(public_key, "header.payload") サーバーは「HS256 ならキーは対称鍵」と思い、手元にある RSA 公開鍵をそのまま HMAC の鍵として使ってしまう。攻撃者は公開鍵を入手できる前提(/.well-known/jwks.json などで公開)なので、自分で偽造した署名がそのまま通ります。
対策
- アルゴリズムごとに鍵の型を分ける:
HS256用の鍵とRS256用の鍵を別変数で持ち、そのアルゴリズムにしか使わない - 1 で挙げたとおり、ヘッダの
algを信用しない・許可リストを固定する
3. kid injection による任意の鍵への誘導
kid(key ID)はヘッダに入る、サーバーが鍵を識別するためのフィールドです。
{
"alg": "RS256",
"kid": "my-key-1"
} 実装によっては kid をファイルパスや SQL クエリにそのまま使うことがあり、ここに ../ を入れたり、SQL インジェクションを仕込んだりすると:
"kid": "../../etc/passwd"でローカルファイルを鍵として読み込ませて検証成功"kid": "x' UNION SELECT 'attacker_key' --"で SQL を注入し任意の鍵を返させる
対策
kidはホワイトリストで受ける(Map<string, Key>から lookup)- ファイルパス・SQL 文字列に直接結合しない
- 不明な
kidは即拒否(鍵を探す挙動をしない)
4. exp を検証しない or nbf・iat を見ていない
exp(expiration)は仕様上任意フィールドです。発行側が入れていなければ、検証側でその不在を有効として扱ってしまうコードがあります。
if (payload.exp && payload.exp < Date.now() / 1000) throw new Error('expired'); このコードでは payload.exp が未定義のとき検証をスキップしてしまい、期限なしトークンが永続的に有効になります。
加えて、以下も見落とされがち:
nbf(not before):このトークンを使い始めてはいけない時刻。未来のnbfが無視されると、攻撃者が将来発行された風のトークンを今使えるiat(issued at):発行時刻。極端に古いiatのトークンを警戒する(盗まれた古いトークンの再利用検知)
対策
expを必須にし、不在ならエラー- 主要ライブラリには「
requireExpiration: true」のようなオプションがある。ON にする nbfiatも同じく検証- クライアントとサーバーのクロックずれ許容(
leeway)は数秒〜30 秒程度に抑える
5. 公開鍵の rotation・失効が考慮されていない
JWT は短い有効期限(数分〜数時間)で発行し、サーバーが署名鍵を定期的に rotate するのが理想ですが、以下の不備が頻発します。
- JWKS をアプリ起動時に 1 回だけ取得し、以後キャッシュしっぱなし → 新しい
kidが来ると検証失敗 - 漏洩した古い鍵を JWKS から削除しても、攻撃者の手元に既発行のトークンが残っていれば、
expまで使える kidを持たないライブラリ実装で、複数鍵を運用できない
対策
- JWKS は TTL を設けて再取得(数分〜1 時間)
- 既知の
kidだけを受け付け、古いkidは積極的に外す - 漏洩時の緊急対応として、
expの絶対上限を発行側で短くしておく(30 分など)。これより長いexpが乗っているトークンは検証側で拒否 - リフレッシュトークン側で「失効リスト(denylist)」を持つ運用にする
副次的に踏みやすいポイント
主要 5 つから外したが、関連しやすいのも併記。
audを見ていない:他サービスのトークンを流用される(同じ IdP 配下なら攻撃者が別サービスのトークンを取って通す)- 大きすぎる JWT:URL クエリやヘッダのサイズ上限に当たる(ALB / nginx で 8KB が多い)
- JWT に PII を入れている:JWT は base64url で暗号化されていないため、payload は誰でも読める
- クライアント側で署名検証してない:クライアントの鍵が漏れる。署名検証はサーバ側だけで OK
チェックリスト
実装レビュー時に毎回見るべき項目:
algorithmsを定数で固定しているかnoneが許可されていないか- HS256 と RS256 の鍵を別管理しているか
kidをホワイトリストで処理しているかexpを必須にしているかnbfiataudも検証しているか- JWKS の再取得タイミングが定義されているか
JWT デコーダーで実際のトークンの header / payload / signature を分解して、alg や kid の中身を確認できます。
まとめ
JWT の脆弱性は仕様の柔軟さに由来するものが多く、「ヘッダの alg をそのまま信じない」「期限・発行者・対象者をすべて検証する」「鍵管理を独立させる」という 3 点を守れば、5 つの落とし穴の大半は塞がります。古い CVE と同じ形で再発するので、新規実装時もコードレビューで上記チェックリストを通してください。