メインコンテンツへスキップ
友田 陽大
アプリ層セキュリティ
Supabase
RLS
Next.js
セキュリティ
TypeScript
アーキテクチャ設計
生成AI

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

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

公開日
読了時間
20分
著者
友田 陽大
シェア

最初に結論を述べます。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)。しかも2019年の初版以来、一度も首位を譲っていません。「めったに起きない高度な攻撃」ではなく、「最も普通に、最も頻繁に起きる漏洩」です。

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

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)。さらに重要なのは、モデルが大きく賢くなっても、セキュリティの成績は横ばいだったという点です。コードは「より動く」ようになったが、「より安全」にはなっていない。

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

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

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

この2つは、現実の重大インシデントとして公開されています。2025年に登録された 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 キーはブラウザに配られる公開鍵で、秘密ではありません。

-- 危険: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段階であることが重要です。

-- ステップ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)。

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

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

// 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になる)。

// 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ポリシー:

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点を規律として徹底します。

// 修正案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 にこの検出を authz/idor-tainted-scope というルールとして実装しています。

# インストール不要・設定不要でスキャン
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が実行時に確定
# 自分のアプリに対する安全・非破壊なプローブ(所有権の食い違いを実行時に確認)
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」は、私が自作のセキュリティツール Aegisnpx @aegiskit/cli scan)で検出対象にしているまさにそのクラスの欠陥であり、受託・自社開発の両方で実際に塞いできた領域です。AIで速く作ること自体は正しい。速く作ったものを、漏らさず安全に固める——その検証の仕組みづくりや、既存のNext.js × Supabaseアプリの認可レビューが必要であれば、お気軽にご相談ください。


参考資料

友田

友田 陽大

経済産業大臣賞 受賞プロダクト開発者。TypeScript + Python + AWS で、SaaS・業界DX・ 実用レベルの生成AI(RAG)を、要件定義からインフラ・運用まで一人で完遂します。

この記事の脆弱性、あなたのアプリは大丈夫ですか?

Next.js × Supabase の認可・RLS を、専門家が監査します

この記事で扱った IDOR・RLS の設計ミス・テナント越境は、ライブラリでは直せない「縦のリスク」です。認可レビューから修正設計・実装まで、セキュリティ監査として承ります。まず無料の OSS で現状を可視化してからでも構いません。

プロジェクト単位(請負)・技術顧問のどちらにも対応可能です。まずは30分の無料技術相談から。