私の JWT は安全か?トークン検証はどう回避されるのか
JWT の多くの安全事故は暗号そのものの破綻ではなく、検証器の設定や実装の誤りです。攻撃者が書いたトークンを受け入れる、または検証自体をしないことが原因になります。未検証のトークンに検証方法を左右させないための主な攻撃経路と防御を解説します。
署名を検証したので安全だ、と感じるかもしれない。その方向性は間違っていないが、「検証した」と「正しく検証した」は別のことだ。多くの JWT セキュリティ事故は、この差から起きる。
重要な事実がある。JWT の多くの安全事故は、暗号そのものが破られた結果ではない。 攻撃者は SHA-256 の逆算や RSA 鍵の素因数分解ではなく、検証器の設定、鍵の扱い、アプリケーションロジックの誤りを突く。狙いは、攻撃者が作ったトークンをコードに「有効」と判断させることだ。
前回の記事で説明したように、署名が検証される前の header と payload は攻撃者が制御できる入力である。header には alg や kid のように、検証の流れへ影響する項目もある。これらを検査すべき入力ではなく命令として扱うと、検証器は誤った信頼判断をするおそれがある。署名の検証に成功した後は、保護された header も署名により保護される。危険があるのは検証前だけだ。
したがって、「JWT は本質的に安全か」だけを問うべきではない。サーバーが決めるべきことを、検証器がトークンに委ねていないかを確認する必要がある。以下の攻撃には、トークンが検証器自身の判断に影響するという共通点がある。対策はシンプルで、トークンを実行すべき命令ではなく、検証するデータとして扱うことだ。
核心:検証器はなぜ受け入れるべきでないトークンを受け入れるのか
前回の記事の二つの問いに戻ろう。トークンには何が書かれているか、そして信頼できるか。前者は直接読めるが、後者には検証が必要である。本稿の攻撃は後者を狙い、代表的な手口は次の五つだ。
- 検証を飛ばす:検証すべきものがないと判断させる。
- 誤った鍵を使わせる:攻撃者も入手できる鍵を使わせる。
- 鍵の選択を操る:攻撃者が制御する鍵を選択または取得させる。
- 鍵を推測する:弱いシークレットを利用する。
- 有効なトークンを別のサービスで使う:トークンは本物でも、そのサービス向けではない。
このうち四つは、署名アルゴリズムそのものを弱めるのではなく、検証の流れを回避または誤誘導する。さらに基本的な失敗として、検証そのものが行われない場合もある。
最も基本的な誤り:署名を検証していない
最も見落としやすい問題は、検証関数がまったく呼ばれていないことだ。コード上では、トークンを読む処理と検証する処理は似て見える。多くのライブラリにはデコード用と検証用の API がある。デコードはクレームを読み、完全な payload を返すことがあっても認証はしない。期待する鍵と規則で署名を確かめるのは検証だけだ。デコード結果から直接 user.role を読むと、テストは通っても署名検証が省略されていることに気づけない。攻撃者は payload を書き換えるだけでよい。
これは前回の記事で最も重要な結論だった。デコードは信用ではない。 JWT の安全性を確認するときは、まず検証関数を呼んでいるか、正しい鍵を渡しているか、検証結果に基づいて後続処理をしているかを確認する。鍵を指定せずに任意のトークンを JWT インスペクターへ貼り付けると、JSON は読めるが、状態は 「Decoded, not verified」(デコード済み、未検証) のままだ。デコードは信頼判断ではなく、バックエンドでも同じ境界を守る必要がある。
攻撃一:alg: none による検証の回避
JWA 仕様には、alg が none で署名が空の「無保護」の JWS がある。これは完全性を別のプロトコル層が保証する特殊な場面だけを想定している。検証器が header の alg をそのまま検証方法として使い、サーバーの許可リストや鍵種別と照合しなければ、攻撃者は alg を none に変更し、署名を除いて任意の payload を作れる。検証器は「署名がない」を「検証が不要」と誤解し、偽造トークンを受け入れてしまう。
準拠した現代のライブラリは、アプリケーションが明示的に許可しない限り無保護の JWS を拒否する。実際のリスクは、古い依存関係、アルゴリズム設定を隠すラッパー、または保護を無効にした設定にある。エラーが出なかったことを、安全な検証が完了した証拠と考えてはいけない。
検証器は未検証の header から alg を読んでもよいが、比較する入力としてのみ扱う。サーバーで設定した許可アルゴリズムと鍵種別に一致するときだけ検証を続け、それ以外は none を含めて拒否する。本サイトの JWT インスペクターにも none の検証モードはない。この原則によるものだ。
攻撃二:アルゴリズムと鍵種別の混同
RS256 と HS256 の混同は、公開された RSA 公開鍵を HMAC シークレットとして誤用する問題だ。
RS256 では、発行者が RSA 秘密鍵で署名し、サービスは対応する公開鍵で検証する。公開鍵は公開配布できる。検証器がトークンの alg にアルゴリズムを決めさせると、攻撃者は header の RS256 を HS256 に変え、公開鍵のバイト列を HMAC シークレットとして MAC を計算できる。サービス側も同じ公開鍵で誤って HMAC を実行すれば、MAC は一致し、偽造トークンが受け入れられる。
根本原因は、アルゴリズムと鍵種別をサーバー側で固定していないことにある。設定で許可するアルゴリズムを限定し、各鍵を一つの用途にだけ結び付ける。たとえば RSA 公開鍵は RS256 の検証にのみ使い、HS256 の HMAC シークレットとしては絶対に使わない。現代のライブラリは通常、許可アルゴリズムの明示的なリストを要求する。header からアルゴリズムを推測するライブラリやラッパーは、依然としてリスクになる。RSA 鍵ジェネレーターで生成する公開鍵は共有する前提のため、対称シークレットとして安全に使うことはできない。
攻撃三:安全でない鍵の参照:kid、jku、x5u
攻撃一と二は、検証器がすでに持つ鍵を誤用させる。こちらは、検証器がどの鍵を使うかに影響を与える攻撃である。
kid(key ID) は鍵集合から候補の鍵を探すために使うが、検証前は攻撃者が制御できるテキストにすぎない。これを直接ファイルパス、SQL、LDAP クエリへ埋め込むと、パストラバーサルやインジェクションを招く。固定した信頼できる鍵集合、またはキャッシュ済み JWKS の不透明なインデックスとしてのみ使うこと。未知の kid は拒否し、パス、クエリ、URL の組み立てに使ってはならない。
jku(JWK Set URL) と x5u(X.509 URL) は、リモートの鍵を参照できる。対応自体が問題なのではなく、トークンが示す任意の URL を盲目的にたどることが問題だ。多くのサービスでは両方を無視し、あらかじめ設定した発行者メタデータだけを使う。利用が必要なら、信頼するホストを限定し、HTTPS と証明書検証を必須にし、リクエストに cookie や資格情報を付けない。JWT インスペクターではこれらの header を確認できるが、URL は取得しない。X.509 証明書を参照するトークンは、header の参照先を信用せず、証明書デコーダーで主体と有効期間を確認する。
攻撃四:弱い HMAC シークレット
回避の手口がなくても、HMAC シークレット自体が弱いことがある。HS256 の安全性は共有シークレットに依存する。secret、password、changeme、アプリ名のような値は推測されやすい。有効なトークンを一つ入手すれば、攻撃者はワードリストに対して HMAC をオフラインで計算し、一致する値を探せる。サーバー側のレート制限やロックアウトはない。シークレットが知られると、HMAC の検証用シークレットは署名用でもあるため、任意のトークンを発行できる。
長さとエントロピーは別物だ。RFC 7518 は HMAC 鍵をハッシュ出力と少なくとも同じ長さにすることを求める。HS256 では 256 ビットである。しかし人が入力した長いシークレットも推測されうる。CSPRNG で少なくとも 32 バイトを生成し、ライブラリの要求どおりに符号化して、ソースコードではなく管理されたシークレットとして保存する。パスワードジェネレーターは開発やテスト向けの高エントロピー文字列に使えるが、本番鍵はプラットフォームで承認された鍵管理の仕組みで扱う。
誰が鍵を持つかも重要だ。検証側がトークンを発行すべきでない場合や、検証を十分に管理できない信頼境界の外へ配る場合は、公開鍵署名(RS/ES/EdDSA)を優先する。検証用の材料が漏れても偽造はできない。候補の鍵で token を検証できるかは JWT インスペクターで確認できるが、「Signature verified」(署名検証済み) はその鍵がその token を検証できることを示すだけだ。本番鍵を未承認環境で試す理由にはならない。
攻撃五:audience・issuer・型の検証漏れ
これは偽造ではない。トークンの署名は有効で期限も切れていないが、現在のサービスが受け入れるべきではない場合がある。
有効な署名が証明するのは、トークンが署名後に改ざんされておらず、期待する鍵で検証できることだけだ。呼び出し元の身元や権限まで証明するものではない。これらは iss、sub、aud などのクレーム、トークン種別、サーバー側の規則に依存する。署名を検証しても aud と iss を省略すると、confused deputy(混乱した代理) が起きる。サービス A 向けに正当に発行されたトークンをサービス B で再生し、B が有効な署名だけを理由に受け入れてしまう。署名検証後に、信頼する iss と aud を厳密に比較すること。「〜で始まる」や緩い正規表現で audience を比較してはいけない。
前回の記事の具体例は、access token だけを受け取る API に OIDC の ID token が提示されるケースだ。aud だけでは常に区別できないため、トークン種別も確認する。RFC 9068 に従う JWT access token は at+jwt を使う。それ以外では、プロトコルが期待する型またはプロファイルを定義して強制する。access token が必要な場所で ID token を受け入れてはならず、逆も同様だ。異なる資格情報は、audience だけでなく相互に排他的な規則で検証する。
盗まれたトークンも偽造を必要としない。検証器は、正当な保有者とログや侵害端末からコピーした攻撃者を区別できず、有効期間内なら両者とも通過する。jti(token ID)は単独では再生を止めない。消費済みまたは失効済み ID をサーバー側で保持し、使用時に原子的に照合して初めて役立つ。DPoP や相互 TLS のような sender-constrained token は、cnf を通じてトークンをクライアントの鍵へ結び付ける。盗まれたトークンを自動的に無効にするものではないが、クライアント秘密鍵が守られていれば、コピーした bearer token だけを再生する価値を下げられる。短い有効期間と失効も重要だ。
信頼できる検証フローを作る
個別にパッチを当てるより、サーバーの設定が最初から最後まで判断する検証フローを作る方がよい。
- 鍵はトークンではなく設定から解決する。 各発行者の署名鍵を前もって把握する——信頼できる発行者メタデータから固定した JWKS URI、または直接保持する鍵。header の
kidはそれらの中で選ぶためだけに使う。未知のkidなら JWKS を一度だけ更新し、なお見つからなければ外に取りに行くのではなく拒否する。ローテーション失敗は「フェイルクローズ」であるべきだ。 - アルゴリズムを固定し、鍵に束縛する。 設定したアルゴリズムだけを、それぞれが束縛された鍵型でのみ受け入れる——攻撃一と二の二つの規則を、いかなる署名計算の前にも強制する。
- 入力に境界を設ける。 過大なトークンを拒否し、拡張を理解できない
critheader をすべて拒否する——仕様の要求であり、トークンがあなたの承知しない処理を要求するのを止める。 - 検証してから、クレームを検証する。 署名が通ったあとで初めて、
expとnbf(適度なクロックスキューを許容)、続いてiss、aud、トークンのtyp、そしてこのエンドポイントが要求するクレームを——それぞれ厳密に比較して——検証する。 - 入口で認証し、リクエストごとに認可する。 検証済みのトークンは認証されただけで、認可されたわけではない。権限はサーバー側でリクエストごとに強制する。そして認可が即座に変わらねばならないとき——取り消された役割、無効化されたアカウント——は、期限まで凍りついた
roleクレームに頼らず、ライブの状態を確認する。
これらのパターンの一部は、最初にライブラリの CVE として現れた。一方で、アプリケーションの設定や鍵の引き当てコードだけから生じるものもある。手書きの検証ではなく、よく保守された JWT ライブラリに頼り、パッチを当てつづけたうえで、設定を狭く保つこと。受け入れるアルゴリズム、鍵の出どころ、トークンのサイズを絞るほど、次の不具合が潜む場所も少なくなる。
本番環境の強化チェックリスト
上記の攻撃には、トークンがサーバーコードの決定に影響しているという共通点がある。対策は次の八項目にまとめられる。
- 本当に検証する。 検証関数を鍵とともに呼び、返ってきた結果に従って動く。
decodeはverifyではなく、「デコードできた」はセキュリティ検査ではない。 - アルゴリズムを制約する。 header から
algを読むのは設定した許可リストと照合するためだけにし、各鍵を単一のアルゴリズムに束縛する。noneとリストにないものはすべて拒否する。 - 鍵の場所をトークンに選ばせない。
kidは事前設定した鍵か固定 JWKS への不透明なインデックスとして解決する。jku/x5uは、信頼するホストを固定し、HTTPS と証明書検証を要求し、資格情報を送らない場合を除いて無視する。 - 非対称の公開鍵は検証専用に。 公開鍵が HMAC シークレットとして受け入れられるのを止めるのは、鍵とアルゴリズムの束縛(規則 2)だ。
- シークレットを高エントロピーに。 CSPRNG からの少なくとも 32 バイトを、管理されたシークレットとして保持。検証側が発行できてはならない場所では、公開鍵署名を優先する。
- クレームを厳密に検証する。 署名が通ったら
exp、nbf、iss、aud、そしてトークンのtypを、逐一厳密に比較する——他人宛て、あるいは種類の違うトークンは、署名が完璧でも拒否だ。 - 認証は認可ではないと肝に銘じる。 権限はリクエストごとにサーバーで強制し、即座に取り消すべきものはライブの状態を確認する。
- トークンは漏れると想定する。 短命にし、
jtiをサーバー側の状態で裏打ちして再生を捕まえ、価値の高い API では sender-constrained トークンを検討する——有効で盗まれたトークンは、問題なく検証を通るのだから。
前回の記事の規則も一つ、ここに属する。payload は誰でも読めるので、決して秘密を入れない。即座に変更・取り消しが必要な権限を、長寿命 token の claim だけに頼らない。リスクに応じて短い有効期限、失効処理、またはオンラインの認可確認を使う。
このために暗号学者より詳しくなる必要はない。検証するか、どのアルゴリズムと鍵を受け入れるか、鍵をどこから取得するかは、検証器が自身の設定で決める。トークンは検証する証拠として扱う。この方針は主要な検証回避経路を閉じるが、トークン盗難、認可、鍵運用には別の対策も必要だ。
検証だけでは解決できない問題もある。本物で有効なトークンが攻撃者の手に渡ることだ。続く二回で分けて扱う。まず トークンをブラウザのどこに置くか(localStorage、cookie、そして XSS と CSRF のトレードオフ)、次に 短命な access token、refresh token のローテーション、失効 だ。署名はトークンが改ざんされていないことを示す。ライフサイクル管理は、本物のトークンが盗まれた後の被害を抑える。