最初に結論を述べます。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つあります。
- RLSの有効化は fail-secure。ポリシーが1つも無ければ、デフォルトは「全拒否」です。「とりあえず有効化」しておけば、少なくとも無防備な全件公開は止まります。
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を再現して確定させます。やり方はシンプルです。
- テスト用に2つのアイデンティティ(ユーザーAとユーザーB)を用意する
- AでログインしてAのリソースID(例:請求書1024)を取得する
- Aのセッションのまま、Bのリソースを指すIDを叩く
- 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」は、私が自作のセキュリティツール Aegis(npx @aegiskit/cli scan)で検出対象にしているまさにそのクラスの欠陥であり、受託・自社開発の両方で実際に塞いできた領域です。AIで速く作ること自体は正しい。速く作ったものを、漏らさず安全に固める——その検証の仕組みづくりや、既存のNext.js × Supabaseアプリの認可レビューが必要であれば、お気軽にご相談ください。