JWT 実装で 2026 年も踏まれている 5 つの落とし穴

約8分

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(公開鍵暗号)で運用しているサービスに対して、algHS256 に変えたトークンを送る攻撃。

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 nbfiat を見ていない

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 にする
  • nbf iat も同じく検証
  • クライアントとサーバーのクロックずれ許容(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

チェックリスト

実装レビュー時に毎回見るべき項目:

  1. algorithms を定数で固定しているか
  2. none が許可されていないか
  3. HS256 と RS256 の鍵を別管理しているか
  4. kid をホワイトリストで処理しているか
  5. exp を必須にしているか
  6. nbf iat aud も検証しているか
  7. JWKS の再取得タイミングが定義されているか

JWT デコーダーで実際のトークンの header / payload / signature を分解して、algkid の中身を確認できます。

まとめ

JWT の脆弱性は仕様の柔軟さに由来するものが多く、「ヘッダの alg をそのまま信じない」期限・発行者・対象者をすべて検証する」「鍵管理を独立させる」という 3 点を守れば、5 つの落とし穴の大半は塞がります。古い CVE と同じ形で再発するので、新規実装時もコードレビューで上記チェックリストを通してください。