JWTの「検証漏れ」は、泥棒に合鍵を渡すのと同じだ:iss, aud, expを軽視するな
「JWT(JSON Web Token)を使っています。署名検証はライブラリに任せているので大丈夫ですよね?」
セキュリティレビューの現場で最も耳を塞ぎたくなる言葉の一つだ。多くのエンジニアは「署名の検証(Verify Signature)」さえしていれば安全だと誤解しているが、それは大きな罠だ。署名が正しくても、クレーム(Payload内の属性)の検証が不十分であれば、システムは無防備な状態に等しい。
今日は、JWTの「iss」「aud」「exp」を放置することで、攻撃者にどのような特等席を用意してしまうのか、そしてどうやってそれを防ぐのか、現場のリアルな視点から解説する。
—
1. なぜ「iss」「aud」「exp」の検証が必須なのか?
JWTは「自己完結型」のトークンだが、それは「検証も自前で完結させろ」という意味でもある。
- iss (Issuer / 発行者): どこの認可サーバーが発行したか。これが未検証だと、攻撃者が自分で立てた偽の認可サーバーで発行したトークンを、君のシステムが受け入れてしまう。
- aud (Audience / 対象者): このトークンは「君のシステム」に向けられたものか。これが未検証だと、Aというシステム向けに発行されたトークンを、君のシステム(B)で使い回す「リプレイ攻撃」が成立する。
- exp (Expiration Time / 有効期限): 期限切れかどうか。これが未検証だと、一度盗まれたトークンは、攻撃者がパスワードを変更しようが何しようが、永遠に有効な「魔法の合鍵」として君臨し続ける。
攻撃者の視点:
君のアプリが exp を見ていなければ、攻撃者は数ヶ月前に盗んだトークンを捨てずに保管し、深夜の監視が手薄な時間帯にバックドアを仕込む。aud を見ていなければ、脆弱な別アプリのJWTを流用して、君の管理画面にログインを試みる。これらは古典的だが、今なおインシデントのトップランナーだ。
—
2. セキュアな実装サンプル(Node.js / jsonwebtoken)
ライブラリを「デフォルトのまま」使うのが一番危ない。検証オプションを明示的に指定し、ホワイトリスト運用を行うのがプロの流儀だ。
const jwt = require(‘jsonwebtoken’);
// 厳密な検証オプションを定義する
const verifyOptions = {
issuer: ‘https://auth.example.com’, // 信頼できる発行元を固定
audience: ‘https://api.my-service.com’, // 自システムの識別子
algorithms: [‘RS256’], // HS256(対称鍵)は避け、必ずRS256(非対称鍵)を使う
clockTolerance: 30 // 時計のズレを考慮しつつも、過度な猶予は与えない
};
try {
// 公開鍵を使って検証
const decoded = jwt.verify(token, publicKey, verifyOptions);
// さらに、アプリ固有のロジックで「ユーザー状態」を再確認する
// トークンが正しくても、DB上でアカウントが凍結されていないか?
if (await isUserBlocked(decoded.sub)) {
throw new Error(‘User account is suspended’);
}
console.log(‘検証成功:’, decoded);
} catch (err) {
// ここでログを出す際は、機密情報を含めないよう注意
console.error(‘JWT検証失敗: 不正なトークンまたは期限切れ’);
}
—
3. インフラレイヤーでの「防御の多層化」
コードレベルでの検証に加え、インフラ側でもJWTを保護する姿勢を持とう。特に、API GatewayやNginxレベルでの簡易チェックは、アプリケーションまで攻撃を通さないための「防波堤」になる。
Nginxでの検証設定(Luaモジュール使用例)
アプリケーションの手前にあるNginxで exp のチェックを代行させることで、期限切れトークンをアプリケーションまで到達させない設計にする。
Nginx + lua-resty-jwt の例
location /api/ {
access_by_lua_block {
local jwt = require “resty.jwt”
local jwt_obj = jwt:verify(“MY_SECRET_KEY”, ngx.var.http_authorization)
— 有効期限の強制チェック
if not jwt_obj.verified then
ngx.exit(ngx.HTTP_UNAUTHORIZED)
end
}
proxy_pass http://backend_upstream;
}
—
4. プロの現場で守るべき3つの鉄則
1. HS256(対称鍵)は使うな: 署名鍵と検証鍵が同じだと、万が一ソースコードが流出した際に全トークンが偽造可能になる。必ずRS256やES256のような「非対称鍵」運用を行うこと。
2. exp は短く、かつ更新可能に: トークンの有効期間は15分〜1時間程度に留め、リフレッシュトークン(RefreshToken)を用いたセッション更新フローを実装せよ。
3. エラーメッセージを詳細にするな: クライアントへのレスポンスで「Issuerが違います」や「Audienceが不正です」と返してはならない。攻撃者に「どこまで正解に近づいているか」のヒントを与えることになる。常に「Invalid Token」とだけ返し、詳細な理由はサーバーのログにのみ残すこと。
まとめ
JWTの検証は「ライブラリに任せた」時点で、セキュリティの放棄が始まる。iss で発行元を縛り、aud で目的を限定し、exp で寿命を管理する。この3つを徹底するだけで、君のアプリの強度は劇的に向上する。
泥臭いかもしれないが、こうした「当たり前の検証」を徹底できるエンジニアこそが、最後にインシデントの火種を消し止めるのだ。さあ、今すぐプロジェクトのソースコードを開いて、検証オプションが空っぽになっていないか確認してくれ。
コメント