2
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

JWT と認可境界の誤解から生まれる IDOR リスクと、その再設計プロセスの記録

Last updated at Posted at 2025-12-07

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が送られてきたら、サーバ側では次の流れで検証します。

  1. トークンの存在確認
  2. 署名検証
  3. 有効期限チェック
  4. 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 を受け取らない設計です。

Before(今回修正したパターン)

@GetMapping("/hoge")
public ResponseEntity<?> getData(
    @RequestParam Long userId,  // クライアントから受け取る
    @RequestHeader("Authorization") String token
) {
    // 認可チェックが必要
    String tokenUserId = jwtService.extractUserId(token);
    if (!userId.equals(Long.parseLong(tokenUserId))) {
        throw new AccessDeniedException("not authorized");
    }
    // 処理
}

問題点:

  • 認可チェックの実装が必須
  • 実装漏れのリスクが残る
  • コードが複雑化

After(より安全な設計)

@GetMapping("/hoge")
public ResponseEntity<?> getData(
    @RequestParam String startDate,
    @RequestParam String endDate
    // userId パラメータなし
) {
    // JWTから直接取得
    String userId = SecurityContextHolder.getContext()
        .getAuthentication().getName();
    
    // 認可チェック不要!
    var result = someService.getData(startDate, endDate, userId);
    return ResponseEntity.ok(result);
}

メリット:

  • ✅ IDOR が原理的に発生しない
  • ✅ 認可チェックのコードが不要
  • ✅ 実装漏れのリスクがゼロ
  • ✅ コードがシンプル

なぜそうしなかったのか

ただし、この設計が常に適用できるわけではありません。

適用できないケース:

  • 管理者機能
    管理者が他ユーザーのデータを閲覧する必要がある

  • マイクロサービス間の呼び出し
    サービスAがサービスBに「このuserIdのデータをください」と依頼する

  • 既存フロントエンドとの互換性
    大規模なフロントエンド改修が困難

  • 家族アカウントなど複雑な権限モデル
    1つのJWTで複数ユーザーを管理

今回はこれらの要件を考慮し、「認可チェック付きでパラメータを受け取る」設計を選択しました。


セキュリティ設計の優先順位

セキュリティ対策には優先順位があります。

優先度 アプローチ 効果
🥇 最優先 設計で防ぐ userIdパラメータを受け取らない 脆弱性が発生しない
🥈 次点 コードで防ぐ 認可チェックを実装 正しく実装すれば安全
🥉 最後の砦 テストで検知 異常系テスト 問題を早期発見

理想的には:

  1. まず「設計で防げないか」を検討
  2. 不可能な場合に「コードで防ぐ」を選択
  3. そして「テストで検証」を必ず実施

まとめ:設計思想として持っておくべきこと

今回の事象を通じて得られた教訓:

「認可チェックを正しく実装する」より
「認可チェックが不要な設計にする」方が安全

もちろん現実には要件の制約があります。
しかし、新しいAPIを設計する際は常に

「そもそもこのパラメータは必要か?」

と問い直すことが、セキュリティ設計の第一歩です。


■ なぜテストでは気づけなかったのか(異常系の盲点)

今回の事象は「テストがなかったから」ではありません。
テストの前提が誤っていたことが根本原因でした。

当時は以下のような前提で検証していました。

前提:クライアントが送ってくる userId は正しい
検証:正常系のレスポンスが返ること

しかし、この前提こそが IDOR の温床です。
本来必要だったのは次の観点でした。

クライアントの userId を書き換えても本人データ以外は取得できないこと
署名付き tokenUserId と一致しない場合は拒否されること

つまり 正常系ではなく異常系の検証が必要だったのです。

追加すべきだったテスト観点(一例)

観点 内容
userId 改ざん リクエストパラメータの userId を他者の値に変更してアクセス
トークン不一致 JWT の userId と request.userId が異なる場合の挙動
トークン改ざん Payload を書き換えたトークンを送信した際、署名検証で破棄されること

この視点が欠けていたため、
認証済み=認可も問題ないという誤った前提がテスト工程にまで伝播し、
結果として設計上の課題が浮上しないまま残存していました。

セキュリティは “正常に動くか” ではなく
“正常に動いてはいけない場面で正しく止まれるか” が本番です。


■ まとめ

今回の経験を一文で表すと

IDではなく、署名を信じよ。

IDORは「チェック漏れ」ではなく
信頼境界の誤配置から生まれます。

逆に言うとこの境界を正しく引ければ、

  • UIがどうであれ
  • クライアントが何を送ってこようと
  • 攻撃者が何を弄ろうと

認可は揺るぎません


■ おわりに

IDORは初歩的に見えて、実際には

「どこまでを信頼し、どこからを疑うのか」

という高度な設計判断が問われるトピックです。

今回の事象を通じて、認可はコードではなく思想から始まるという感覚を強く持ちました。

この視点があれば、新しいAPIや機能追加を行う際にも、同じミスは起こらないはずです。

2
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?