JWT と認可境界の誤解から生まれる IDOR リスクと、その再設計プロセスの記録
※ 過去プロジェクトで遭遇した設計課題の整理となります。
IDOR(Insecure Direct Object Reference)は、ちょっとした設計上の油断から忍び込む脆弱性です。
- 「ログインしてるから大丈夫だろう」
- 「このIDはクライアントが持っているものだから問題ない」
- 「後で認可入れるつもりだった」
このような思い込みが積み重なると、本人ではないのに他者のデータが取得できてしまう状態が静かに成立します。
今回は、実際に見つかった設計上の課題を契機に、
- IDORの本質
- どこで境界を誤認しやすいのか
- JWTを用いた根本的な改善手法
- 設計レベルでの再発防止の考え方
を、抽象化して整理しました。
サービス名や詳細は伏せていますが、どのようなサービスにも起こり得る話として読んでいただけると思います。
■ まず、IDORとは何か
IDORとは、
外部から渡されたリソースIDを、本人であるかどうか確認せずそのまま処理してしまう設計上の欠陥
のことです。
例:
GET /records?userId=123
この userId=123 が、本人である保証はどこにもありません。
URLを書き換えられれば、例えば
GET /records?userId=456
で他者のデータが取得できてしまいます。
問題の本質は
認証(誰か)→ 通っている
認可(何をして良いか)→ していない
という状態にあります。
■ なぜ発生したのか(認可境界の誤認)
今回のケースでは、クライアント側が持っている userId をそのまま信頼し、
(hogeエンドポイントします)
/hoge?userId=◯◯
のようなリクエストを受け付けていました。
しかし、
- localStorage は書き換え可能
- JavaScript も書き換え可能
- HTTPパラメータも当然操作可能
という性質を考えれば、クライアント起点のIDは信頼してはいけないのは明らかです。
システム上は他のAPIが適切な認可処理を実施していたため、今回のAPIだけ設計から漏れていた、という典型パターンでした。
仕様変更や機能追加の過程で「認証済みである」という事実を
認可ロジックと同一視してしまい、境界の見直しが後手に回ったことが原因でした。
**IDORは脆弱性というより「思い込みから生まれる設計事故」**に近いと言えます。
■ JWT と信頼境界の再定義
今回の対策では、信頼できるIDの出所を変更しました。
Before
request.userId を信用
↓
攻撃者が書き換えればそのまま処理
After
request.userId は一切信用しない
JWT の署名済み userId のみ信頼する
ここで重要なのは、書き換え可能な値と、書き換え不可能な値を明確に分離した点です。
■ JWT の構造と「どこが信頼できるのか」
JWTは以下の3つで構成されています。
header.payload.signature
| 部分 | 役割 | 改ざん可能性 | サーバが信頼すべき対象 |
|---|---|---|---|
| Header | アルゴリズム情報 | 改ざん可能 | 信頼しない |
| Payload | userId などの値 | 改ざん可能 | 署名が一致する場合のみ条件付き |
| Signature | Header+Payload を秘密鍵で署名 | 改ざん困難 | 信頼できる唯一の根拠 |
Payload は Base64URL でエンコードされたただの文字列なので、誰でも変更できます。
しかし Signature があることで
Payload が改ざんされていない
= 本人性を保証できる
というモデルが成立します。
ここで初めて、
userId を信じるのではなく
署名された userId を信頼する
という構造が意味を持ちます。
■ バックエンドで行う「信頼の連鎖」
JWTが送られてきたら、サーバ側では次の流れで検証します。
- トークンの存在確認
- 署名検証
- 有効期限チェック
- payload.userId を取得 → 認可処理へ
String tokenUserIdStr = jwtService.extractUserId(token);
try {
Long tokenUserId = Long.parseLong(tokenUserIdStr.trim());
if (!userId.equals(tokenUserId)) {
log.warn("Access denied: token userId={} does not match param userId={}",
tokenUserIdStr, userId);
throw new AccessDeniedException("not authorized");
}
} catch (NumberFormatException e) {
log.warn("Access denied: token userId={} is not a valid number", tokenUserIdStr);
throw new AccessDeniedException("Invalid user ID in token");
}
世界線の違い
❌ 間違った世界
requestUserId = 真実
✅ 正しい世界
署名付き tokenUserId = 真実
requestUserId = 参考値
これにより
- userIdを偽造
- payloadを書き換え
といった攻撃は署名検証で即座に破棄されます。
■ この再設計で変わったこと
| 項目 | Before | After |
|---|---|---|
| 信頼するID | クライアント提供の userId | JWTの署名付き userId |
| 認可処理 | あいまい | 明確 |
| 攻撃余地 | 書き換え可能 | 署名検証で無効化 |
| 再発リスク | 高 | 設計思想で根絶可能 |
■ なぜテストでは気づけなかったのか(異常系の盲点)
今回の事象は「テストがなかったから」ではありません。
テストの前提が誤っていたことが根本原因でした。
当時は以下のような前提で検証していました。
前提:クライアントが送ってくる userId は正しい
検証:正常系のレスポンスが返ること
しかし、この前提こそが IDOR の温床です。
本来必要だったのは次の観点でした。
クライアントの userId を書き換えても本人データ以外は取得できないこと
署名付き tokenUserId と一致しない場合は拒否されること
つまり 正常系ではなく異常系の検証が必要だったのです。
追加すべきだったテスト観点(一例)
| 観点 | 内容 |
|---|---|
| userId 改ざん | リクエストパラメータの userId を他者の値に変更してアクセス |
| トークン不一致 | JWT の userId と request.userId が異なる場合の挙動 |
| トークン改ざん | Payload を書き換えたトークンを送信した際、署名検証で破棄されること |
この視点が欠けていたため、
認証済み=認可も問題ないという誤った前提がテスト工程にまで伝播し、
結果として設計上の課題が浮上しないまま残存していました。
セキュリティは “正常に動くか” ではなく
“正常に動いてはいけない場面で正しく止まれるか” が本番です。
■ まとめ
今回の経験を一文で表すと
IDではなく、署名を信じよ。
IDORは「チェック漏れ」ではなく
信頼境界の誤配置から生まれます。
逆に言うとこの境界を正しく引ければ、
- UIがどうであれ
- クライアントが何を送ってこようと
- 攻撃者が何を弄ろうと
認可は揺るぎません。
■ おわりに
IDORは初歩的に見えて、実際には
「どこまでを信頼し、どこからを疑うのか」
という高度な設計判断が問われるトピックです。
今回の事象を通じて、認可はコードではなく思想から始まるという感覚を強く持ちました。
この視点があれば、新しいAPIや機能追加を行う際にも、同じミスは起こらないはずです。