# Supabaseで「他人のデータが見える」IDOR脆弱性はこう生まれる — AI生成のNext.jsコードに潜む認可欠陥を発見・修正する実践ガイド

> AIで量産したNext.js × SupabaseアプリがOWASP API1:2023 BOLA（IDOR）で他人のデータを露出させる仕組みを、CVE-2025-48757とservice_roleキーによるRLSバイパスを軸に解説。WAFやセキュリティヘッダーでは防げない理由と、taint解析・RLS検証・実行時確認の3層で体系的に発見・修正する方法を、脆弱→修正の実コードで示します。

- 公開日: 2026-06-27
- 著者: 友田 陽大
- タグ: Supabase, RLS, Next.js, セキュリティ, TypeScript, アーキテクチャ設計, 生成AI
- URL: https://tomodahinata.com/blog/nextjs-supabase-idor-broken-authorization-rls-detection-guide
- カテゴリ: アプリ層セキュリティ
- 総合ガイド: https://tomodahinata.com/blog/nextjs-supabase-application-security-guide

## 要点

- IDORは『ログイン済みの正規ユーザーがIDを書き換えて他人のデータにアクセスできる』認可欠陥。OWASP API Security Top 10で2019年以来ずっと1位（API1:2023 BOLA）の最頻出リスク
- AI製Supabaseコードでこれが量産される理由は2つ——(1) RLSが未設定のまま公開APIに露出する、(2) service_roleキーでRLSを『正しく』回避したまま所有権チェックを書き忘れる
- service_roleキーはPostgreSQLのBYPASSRLS権限で動くためRLSを完全に無視する。守りの境界は『RLSの有無』ではなく『鍵をどこに置き、所有権をどこで強制するか』に移る
- WAFやセキュリティヘッダーでは防げない。IDORは構文的に正しい認証済みリクエストで、攻撃かどうかは『そのIDが本当にそのユーザーのものか』という業務ロジックだけが決める
- 発見は3層で体系化する——コードのtaint解析（入力→所有権スコープなしのクエリ）、SQLのRLS検証（RLS未設定・request経路のservice_role）、実行時の確認（自分のアプリへの安全なプローブ）

---

最初に結論を述べます。**AIで素早く作ったNext.js × Supabaseアプリが「他人のデータを見せてしまう」のは、ほとんどの場合 IDOR（オブジェクト単位の認可欠陥）が原因で、それは行レベルセキュリティ（RLS）の「中」ではなく「外側」——コードとSQLの継ぎ目——に空きます。** そしてこの穴は、WAFやセキュリティヘッダーといった「横の対策」では構造的に塞げません。攻撃リクエストが、認証も書式も完全に正しい「正規のリクエスト」だからです。

これは「AIを使うな」「Supabaseは危ない」という話ではありません。RLSは強力で、Supabaseは堅牢です。問題は、**認可（authorization）という"縦のリスク"を、AIも開発者も「動いたから大丈夫」で見落としやすい**ことにあります。本記事は、その見落としがどう生まれ、なぜ自動の防御では防ぎきれず、どうすれば体系的に発見・修正できるのかを、実コードと公開された一次情報に基づいて解説します。

---

## 1. IDOR/BOLAとは何か——「認証は通っているのに、認可で漏れる」

まず用語を正確に押さえます。

- **認証（Authentication）**＝「あなたは誰か」を確かめること。ログインがこれです。
- **認可（Authorization）**＝「あなたはこの操作・このデータに対して権限を持つか」を確かめること。

IDOR（Insecure Direct Object Reference）は、後者の失敗です。OWASPの最新版API Security Top 10では **API1:2023 Broken Object Level Authorization（BOLA）** という名前で、**最も深刻なAPIリスクの第1位**に位置づけられています（[OWASP API Security Top 10](https://owasp.org/API-Security/editions/2023/en/0xa1-broken-object-level-authorization/)）。しかも2019年の初版以来、一度も首位を譲っていません。「めったに起きない高度な攻撃」ではなく、「最も普通に、最も頻繁に起きる漏洩」です。

仕組みは拍子抜けするほど単純です。

```text
GET /api/invoices/1024   ← 自分の請求書（正規アクセス）
GET /api/invoices/1025   ← IDを1つ増やすだけ。これが他人の請求書を返したらIDOR
```

ユーザーは正しくログインしており、リクエストの形式も正しい。違うのは、URLに含まれる **オブジェクトID（`1025`）が、そのユーザーの所有物かどうかをサーバーが確かめていない** という一点だけです。OWAPSの例では、車両のVIN（車台番号）をAPIに渡すと、それが本当にログイン中のユーザーの車かを検証せずにデータを返してしまう、というケースが挙げられています。

ここで重要なのは、**この「正規ユーザーが意図的に境界を踏み越える」性質**です。後述するとおり、これが「自動の防御では防げない」ことの根本原因になります。

---

## 2. なぜAIが書いたSupabaseコードはIDORを量産するのか

AI生成コードのセキュリティについては、推測ではなく実測データがあります。Veracodeが100以上のLLMに対して4言語・80のコーディングタスクを課した2025年の調査では、**AIが生成したコードの45%が既知のセキュリティ欠陥を含み、安全だったのは55%にとどまりました**（[Veracode 2025 GenAI Code Security Report](https://www.veracode.com/resources/analyst-reports/2025-genai-code-security-report/)）。さらに重要なのは、**モデルが大きく賢くなっても、セキュリティの成績は横ばいだった**という点です。コードは「より動く」ようになったが、「より安全」にはなっていない。

なぜか。AIは、プロンプトで指示された「やりたいこと（=デモで動くこと）」を最短距離で実現するコードを書きます。「他人のデータを見せない」は、明示的に要求されない限りハッピーパスの外側にあり、**デモでは絶対に顕在化しない**。自分のアカウントで触っている限り、IDを書き換えて他人を覗く操作は誰もしないからです。

Supabaseという組み合わせでは、この一般的傾向が**2つの決まった失敗モード**に収束します。

| 失敗モード | 何が起きるか | RLSとの関係 |
|---|---|---|
| **① RLS未設定の露出** | テーブルにRLSを有効化し忘れ、公開APIから anon キーで全件読める | RLSが「無い」 |
| **② service_role でのバイパス** | サーバー側で service_role キーを使い、所有権チェックなしでIDを受け取る | RLSを「無視している」 |

この2つは、現実の重大インシデントとして公開されています。2025年に登録された **[CVE-2025-48757](https://nvd.nist.gov/vuln/detail/CVE-2025-48757)** は、その典型です。NVDの公式記述はこうです——「Lovable（2025-04-15まで）の不十分なRow-Level Securityポリシーにより、リモートの**未認証**攻撃者が、生成されたサイトの任意のDBテーブルを読み書きできる」。分類は **CWE-863（Incorrect Authorization、不正な認可）**、CVSS基本値は **9.3 CRITICAL** です。

注目すべきは、このCVEがベンダーから **「disputed（係争中）」** とされている点です。プラットフォーム側の主張は「アプリのデータ保護は利用者の責任である」。この主張の是非はともかく、**事実として、IDOR/認可はプラットフォームが肩代わりしてくれない"あなたの責任範囲"だ**ということを、このCVEは雄弁に物語っています。買って済ませられないからこそ、設計と検証で自衛するしかありません。

以降で、2つの失敗モードを実コードで分解します。

---

## 3. 失敗モード①：RLSが有効化されないまま公開APIに露出する

Supabaseは、PostgreSQLのテーブルを **PostgREST** 経由で自動的にREST APIとして公開します。便利な反面、これは「**RLSを有効化していないテーブルは、anon キーを知っていれば誰でも読める**」ことを意味します。anon キーはブラウザに配られる公開鍵で、秘密ではありません。

```sql
-- 危険：RLSを有効化していないテーブル
create table profiles (
  id uuid primary key default gen_random_uuid(),
  user_id uuid references auth.users,
  full_name text,
  phone text,
  stripe_customer_id text
);
-- ↑ enable row level security を書き忘れると…
--   curl "https://<project>.supabase.co/rest/v1/profiles?select=*" \
--        -H "apikey: <anon-key>"
--   が、全ユーザーの氏名・電話番号・Stripe顧客IDを返してしまう
```

これがCVE-2025-48757で起きたことの本質です。テーブルを作るSQLは生成できても、「公開APIに晒される前提でRLSを必ず張る」という運用規律は、AIのデフォルト出力には含まれていません。

修正は単純ですが、**2段階**であることが重要です。

```sql
-- ステップ1：RLSを有効化する（これ自体が fail-secure ＝デフォルト全拒否）
alter table profiles enable row level security;

-- ステップ2：必要なアクセスだけを明示的に許可する
--   有効化しただけでポリシーが無い状態は「誰も読めない」。
--   そこから、自分の行だけ読める許可を足す。
create policy "users read own profile"
on profiles for select
to authenticated
using ( (select auth.uid()) = user_id );
```

ポイントは2つあります。

1. **RLSの有効化は fail-secure**。ポリシーが1つも無ければ、デフォルトは「全拒否」です。「とりあえず有効化」しておけば、少なくとも無防備な全件公開は止まります。
2. `auth.uid()` を **`(select auth.uid())` で包む**。これはSupabase公式が推奨する書き方で、行ごとに関数を再評価せず初期計画でキャッシュさせるため、大きなテーブルで性能が桁違いに変わります。素の `auth.uid() = user_id` と書くと、正しく動くのに遅い、という罠にはまります。

ただし——**「全テーブルにRLSを張れば終わり」ではありません。** ここが本記事の核心です。次の失敗モード②は、RLSが完璧に張られていても、それを丸ごと無効化してしまいます。

---

## 4. 失敗モード②：service_roleキーでRLSを「回避」したまま所有権チェックを忘れる

Supabaseには2種類のサーバー鍵があります。RLSを尊重する **anon キー** と、RLSを完全に無視する **service_role キー** です。後者は、PostgreSQLの **`BYPASSRLS`** 属性を持つ `service_role` として認証されます。Supabase公式ドキュメントは明確にこう述べています——「service_role キーは Row Level Security を完全にバイパスする。サーバー側でのみ使うこと」「RLSポリシーに `service_role` を足しても**何の効果もない**。なぜなら service_role はRLSの中で動くのではなく、RLSそのものを飛び越えるからだ」（[Supabase: Row Level Security](https://supabase.com/docs/guides/database/postgres/row-level-security)）。

ここで何が起きるか。AIエージェント（や急いでいる開発者）は、サーバー側のRoute Handlerで「RLSのポリシー設定に悩むより、管理者クライアントを使えば確実に動く」という誘惑に負けます。そして **service_role でRLSを回避したまま、所有権チェックを書き忘れる**。

### 脆弱なコード：service_roleキー × 所有権チェックなし

```ts
// app/api/invoices/[id]/route.ts — 脆弱（IDOR）
import { createClient } from "@supabase/supabase-js";

// service_role キー：RLS を完全にバイパスする“管理者”クライアント
const supabaseAdmin = createClient(
  process.env.NEXT_PUBLIC_SUPABASE_URL!,
  process.env.SUPABASE_SERVICE_ROLE_KEY!,
);

export async function GET(
  _req: Request,
  { params }: { params: Promise<{ id: string }> },
) {
  const { id } = await params; // ← クライアントが自由に変えられる値（汚染された入力）

  const { data, error } = await supabaseAdmin
    .from("invoices")
    .select("*")
    .eq("id", id) // ← 所有権の条件が一切ない＝IDOR
    .single();

  if (error) return Response.json({ error: error.message }, { status: 404 });
  return Response.json(data); // 他人の請求書もそのまま返る
}
```

このコードの恐ろしさは、**`invoices` テーブルに完璧なRLSポリシーが張ってあっても、まったく効かない**ことです。service_role がRLSを飛び越えるからです。失敗モード①の対策（RLSを張る）を完璧にやり遂げた現場でも、このルートが1本あれば全部漏れます。攻撃は前述のとおり、`/api/invoices/1024` を `/api/invoices/1025` に書き換えるだけです。

この「クライアントが操作できる入力（`params.id`）が、所有権で絞られないまま危険なシンク（DBクエリ）に到達する」という流れを、私は **汚染スコープ型IDOR（tainted-scope IDOR）** と呼んで、後述の静的解析ルールで検出対象にしています。

### 修正案A（推奨）：認可をDBに任せる——ユーザーのRLSを効かせる

最も堅牢なのは、**そもそも service_role を使わず、ユーザーのセッションで動く anon キーのクライアントを使う**ことです。こうすれば、認可はDBのRLSが強制し、コードが所有権チェックを書き忘れても安全側に倒れます（多層防御の"最後の砦"がDBになる）。

```ts
// app/api/invoices/[id]/route.ts — 修正案A：RLSに認可を任せる
import { createServerClient } from "@supabase/ssr";
import { cookies } from "next/headers";

export async function GET(
  _req: Request,
  { params }: { params: Promise<{ id: string }> },
) {
  const { id } = await params;
  const cookieStore = await cookies();

  const supabase = createServerClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!, // ← anon キー＝RLSが効く
    {
      cookies: {
        getAll: () => cookieStore.getAll(),
        setAll: () => {}, // 読み取り専用ルートなので省略
      },
    },
  );

  // RLS が「自分の行」しか返さないため、IDを書き換えても他人の行は0件になる
  const { data, error } = await supabase
    .from("invoices")
    .select("*")
    .eq("id", id)
    .single();

  if (error) return Response.json({ error: "not found" }, { status: 404 });
  return Response.json(data);
}
```

対応するRLSポリシー：

```sql
alter table invoices enable row level security;

create policy "owners read their invoices"
on invoices for select
to authenticated
using ( (select auth.uid()) = user_id );
```

この設計の強みは **「認可をコードから消せる」** ことです。所有権の判定がDBに一元化され、開発者やAIが新しいルートを足すたびに `.eq("user_id", ...)` を書き忘れるリスクが消えます。ETC（Easy To Change）の観点でも、認可ロジックが1か所に集約され、変更が局所化されます。

### 修正案B：service_roleが必要なら、所有権をコードで"必ず"強制する

管理画面やバッチなど、本当にRLSを越える必要がある経路もあります。その場合は、**「誰なのか」をサーバーで確定し、所有権をWHERE句で必ず縛る**——この2点を規律として徹底します。

```ts
// 修正案B：service_role を使うなら、所有権をコードで強制する
import { createClient } from "@supabase/supabase-js";

const supabaseAdmin = createClient(
  process.env.NEXT_PUBLIC_SUPABASE_URL!,
  process.env.SUPABASE_SERVICE_ROLE_KEY!,
);

export async function GET(
  req: Request,
  { params }: { params: Promise<{ id: string }> },
) {
  const { id } = await params;

  // 1) 誰なのかをサーバーで確定する（クライアントの自己申告を信じない）
  const user = await getAuthenticatedUser(req);
  if (!user) return Response.json({ error: "unauthorized" }, { status: 401 });

  // 2) service_role でも、所有権を必ず WHERE で縛る
  const { data, error } = await supabaseAdmin
    .from("invoices")
    .select("*")
    .eq("id", id)
    .eq("user_id", user.id) // ← この1行が無いと IDOR
    .single();

  if (error || !data) return Response.json({ error: "not found" }, { status: 404 });
  return Response.json(data);
}
```

修正案Bは正しく動きますが、**脆い**ことを理解しておくべきです。`.eq("user_id", user.id)` を書き忘れた瞬間に穴が開き、コンパイラもRLSも助けてくれません。だから原則は **A（DBに認可を寄せる）**、Bは「越える理由がある経路だけ、レビューで重点監視する例外」という位置づけにします。

> **Server Actions も同じ穴に落ちます。** `"use server"` のアクションは実質POSTエンドポイントで、`formData.get("id")` や引数で受け取るIDは等しくクライアント操作可能です。「画面に出していないIDだから安全」は成立しません。HTTPで叩けば誰でも任意の値を送れます。同じ所有権ルールを必ず適用してください。

---

## 5. なぜWAF・セキュリティヘッダー・「RLSさえあれば」では防ぎきれないのか

ここまでで、IDORが「コードとSQLの継ぎ目」に空くこと、RLSを張っても service_role 経路で抜けることを見ました。では、なぜ世の中の"セキュリティ製品"で自動的に塞げないのか。**セキュリティ対策には、自動化できる"横"と、自動化できない"縦"がある**からです。

| | 横の対策（自動化できる） | 縦のリスク（検出・警告しかできない） |
|---|---|---|
| 例 | セキュリティヘッダー/CSP、レート制限、CSRF、入力検証、秘密情報の漏洩防止 | **認可（IDOR/BOLA）**、業務ロジックの欠陥、権限昇格 |
| 性質 | アプリ横断で一律に効く | アプリ固有の「誰が何を所有するか」に依存 |
| 自動化 | ライブラリ/設定で塞げる | **ライブラリには塞げない**（あなたのデータモデルを知らない） |

WAFがIDORを防げない理由は、この表に尽きます。`GET /api/invoices/1025` というリクエストは、**HTTPとして完全に正常**です。認証ヘッダーも正しく、SQLインジェクションのような不正な文字列も含まれない。WAFから見れば、これは「正規ユーザーの正規リクエスト」です。それが攻撃になるのは、**「1025番の請求書が、このユーザーの所有物ではない」という、アプリ固有の業務的事実**による——そしてWAFはあなたのデータモデルを知りません。

同様に、セキュリティヘッダーやCSPはXSSやクリックジャッキングには効きますが、認可とは無関係です。RLSは必要不可欠ですが、第4節で見たとおり service_role 経路では飛び越えられ、さらにRLSが効かない領域（`SECURITY DEFINER` 関数、RPC、Storage、外部結合先のテーブル）も残ります。

結論はこうです。**「何かを導入すれば認可が守られる」という製品は存在しないし、存在すると謳う製品はむしろ危険です。**「入れたから大丈夫」という油断こそが、最悪のセキュリティ結果を生みます。認可は、**セキュアな設計（RLS＋コードの所有権チェック）と、それを裏付ける検証**の両輪でしか守れません。自動化できるのは、設計の正しさを"検出・警告"するところまでです。

---

## 6. IDORを体系的に「発見」する——3層の検証

設計で塞ぐと決めたら、次は「塞げているか」をどう確かめるか。検証ファーストの原則に従い、**3つの層**で体系化します。1つの手段に頼らず、静的・構造的・動的を重ねるのがポイントです。

### 層1：静的解析（SAST）——コードのデータフローを追う

「クライアントが操作できる入力（route params、検索クエリ、リクエストボディ、Cookie）が、**所有権で絞られないまま**DBクエリに到達していないか」を、関数内のデータフロー（taint解析）で追います。第4節の脆弱コードでいえば、「`params.id` が `.eq("id", ...)` に渡るのに、`.eq("user_id", ...)` も `auth.uid()` も経由していない」というパターンを機械的に拾います。

これは正規表現では構造的に書けません。"入力がどこから来てどこへ流れるか"を追う必要があるからです。私は自作のOSSスキャナ **[Aegis](https://github.com/tomodahinata/aegis)** にこの検出を `authz/idor-tainted-scope` というルールとして実装しています。

```bash
# インストール不要・設定不要でスキャン
npx @aegiskit/cli scan
```

### 層2：SQL/RLSの検証——認可が"DBに"正しく宿っているか

コードと別に、`supabase/migrations/**.sql` を読んで認可の設計そのものを検証します。具体的には——RLSが無効のテーブル、`WITH CHECK` が無い書き込みポリシー、`using (true)` のような無条件許可、`anon` ロールへの過剰な権限、`search_path` を固定しない `SECURITY DEFINER` 関数（権限昇格の温床）。そして **SQLとコードを突き合わせ**、「RLSの弱いテーブルを、非管理クライアントから実際にクエリしている箇所」を確定した露出として指摘します。Aegisはこれも `supabase/migrations` を解析して行います。

### 層3：動的確認（DAST）——実際に"他人になって"叩く

静的解析は"疑う"ところまで。最後は**自分が所有するアプリ**に対して、実際にIDORを再現して**確定**させます。やり方はシンプルです。

1. テスト用に2つのアイデンティティ（ユーザーAとユーザーB）を用意する
2. AでログインしてAのリソースID（例：請求書1024）を取得する
3. **Aのセッションのまま、Bのリソースを指すIDを叩く**
4. 200が返って他人のデータが見えたら、IDORが**実行時に確定**

```bash
# 自分のアプリに対する安全・非破壊なプローブ（所有権の食い違いを実行時に確認）
aegis probe http://localhost:3000 --correlate
```

静的解析の"疑い"と、動的の"再現"が一致したものは、**確定済み（confirmed-exploitable）**として最優先で直す。これがSAST↔DASTの相関です。加えて、RLSの退行を防ぐなら **pgTAP** でポリシーの回帰テストを書き、CIで「他人の行が見えないこと」を継続的に証明します。

### 正直なスコープ——ツールは"発見"を助けるが、"正しさ"は証明しない

ここは強調させてください。**いかなる静的・動的ツールも、あなたの認可が正しいことを証明はできません。** ツールが見ているのはポリシーや実装の"形"であって、あなたの事業ルールやデータモデルの意味ではありません。クリーンな結果は「よくある罠は踏んでいない」であって、「認可が正しい」ではない。だからこの3層は、**人間によるレビューと脅威モデリングを置き換えるものではなく、補完するもの**です。それでも、最頻出の穴を機械的に潰せる価値は計り知れません。検出が"歯"を持ち、ノイズを増やさず、人間が本当に難しい判断に集中できるようになります。

---

## 7. 発注者・チームのためのIDORチェックリスト

外注したコードでも、AIに書かせたコードでも、本番投入の前に最低限これだけは確かめてください。専門家でなくても判断できるよう、観点と確認方法をまとめます。

| 観点 | 確認すること | 危険信号 |
|---|---|---|
| **RLSの有効化** | 全テーブルでRLSが有効か | `enable row level security` の無いテーブルがある |
| **service_roleの所在** | service_role キーがクライアント/ブラウザに渡っていないか | フロントのコードやネットワークタブに `service_role` が見える |
| **service_role経路の所有権** | service_role を使うAPIで `user_id` 等の所有権条件があるか | IDを受け取って `.eq("id", ...)` だけで返している |
| **IDの差し替えテスト** | 2アカウントで、他人のIDを叩いて弾かれるか | 別ユーザーのデータが200で返る |
| **RLSが効かない領域** | RPC / `SECURITY DEFINER` 関数 / Storage / 結合先に穴が無いか | 「RLS張ったから安全」で思考停止している |
| **検証の自動化** | スキャン・RLS検証・回帰テストがCIにあるか | 「手元で動いた」以外の検証根拠が無い |

発注者の視点で最も効くのは、**「他人のIDを叩いたらどうなりますか？」「service_roleキーはどこで使っていますか？」**の2問です。明確に答えられないなら、認可の検証が設計に組み込まれていない可能性が高い。良い開発者は、これらに即答できます。

---

## よくある質問（FAQ）

**Q. 全テーブルでRLSを有効化すれば、IDORは防げますか？**
A. 多くは防げますが、**不十分**です。第4節のとおり service_role 経路はRLSを飛び越えますし、`SECURITY DEFINER` 関数・RPC・Storage・外部結合先など、RLSが直接効かない領域も残ります。RLSは"最後の砦"として必須ですが、コード側の所有権チェックと二層で守るのが正解です。

**Q. service_roleキーは絶対に使ってはいけませんか？**
A. いいえ。管理処理やバッチでは正当な用途があります。鉄則は2つ——**(1) サーバー側だけで使う（絶対にブラウザに出さない）、(2) 所有権をコードで必ず強制する**。この2つを守れない経路では使わないでください。

**Q. WAFやセキュリティヘッダーを入れれば足りますか？**
A. IDOR対策にはなりません。第5節のとおり、IDORは認証も書式も正しい"正規リクエスト"なので、WAFには攻撃と判別できません。それらはXSSやインジェクション等の"横"のリスクには有効で、認可とは別レイヤーの話です。両方必要ですが、片方がもう片方を代替することはありません。

**Q. AIに「セキュアに書いて」と頼めば直りますか？**
A. 期待しすぎないでください。Veracodeの調査では、モデルが賢くなってもセキュリティの成績は横ばいでした。AIは"動くコード"の生成には強いが、"壊れない構造"を保証はしません。AIの速さを活かしつつ、**検証ゲート（スキャン・テスト・レビュー）を通して初めて本番品質**になります。

**Q. 個人開発や小規模でも、ここまでやるべきですか？**
A. むしろAIで素早く作ったアプリこそ露出例が多い、というのがCVE-2025-48757などの示す現実です。最小構成でも **「全テーブルにRLS」＋「service_role経路の所有権チェック」＋「2アカウントでのID差し替えテスト1本」** の3点だけは必ずやってください。コストはわずかで、防げる事故の大きさは桁違いです。

---

## まとめ：守りの境界は「RLSの有無」から「鍵と所有権の置き場所」へ

要点を整理します。

- IDOR（OWASP API1:2023 BOLA）は、**最頻出かつ最も深刻なAPIリスク**。正規ユーザーがIDを書き換えて他人のデータに触れる、認可の失敗です。
- AI製のNext.js × Supabaseコードでこれが量産されるのは、**(1) RLS未設定の露出**と **(2) service_roleキーによるRLSバイパス＋所有権チェック忘れ**の2つの決まった失敗モードによります。CVE-2025-48757はその現実です。
- **service_role はRLSを完全に飛び越える**ため、守りの境界は「RLSがあるか」ではなく、**「service_roleキーをどこに置き、所有権をどこで強制するか」**に移ります。
- **WAFやヘッダーでは防げません。** IDORは正規リクエストで、攻撃か否かはあなたのデータモデルだけが決める"縦のリスク"だからです。買って済ませることはできません。
- 発見は**3層**で——コードのtaint解析、SQL/RLSの検証、実行時の確認。ただしツールは"発見"を助けるだけで、**認可の正しさは設計と人間のレビューでしか守れない**。

この「RLSの外側に空くIDOR」は、私が自作のセキュリティツール [Aegis](https://github.com/tomodahinata/aegis)（`npx @aegiskit/cli scan`）で検出対象にしているまさにそのクラスの欠陥であり、受託・自社開発の両方で実際に塞いできた領域です。AIで速く作ること自体は正しい。**速く作ったものを、漏らさず安全に固める**——その検証の仕組みづくりや、既存のNext.js × Supabaseアプリの認可レビューが必要であれば、お気軽にご相談ください。

---

## 参考資料

- [OWASP API Security Top 10 — API1:2023 Broken Object Level Authorization](https://owasp.org/API-Security/editions/2023/en/0xa1-broken-object-level-authorization/)
- [NVD — CVE-2025-48757（Lovable / 不十分なRLSによる未認証アクセス、CWE-863、CVSS 9.3）](https://nvd.nist.gov/vuln/detail/CVE-2025-48757)
- [Supabase Docs — Row Level Security（service_roleはRLSをバイパスする）](https://supabase.com/docs/guides/database/postgres/row-level-security)
- [Veracode — 2025 GenAI Code Security Report（AI生成コードの45%にセキュリティ欠陥）](https://www.veracode.com/resources/analyst-reports/2025-genai-code-security-report/)
