結論から書きます。AIが生成したSupabase RLSポリシは「動く」——けれど「認可になっている」とは限りません。そして厄介なのは、その失敗が無限のバリエーションではなく、少数の定型に収束することです。 USING (true) の全開、auth.role() = 'authenticated' の認証のみ許可、WITH CHECK の欠落、ENABLE ROW LEVEL SECURITY の付け忘れ、SECURITY DEFINER の search_path 未固定——毎日Claude Codeで出荷している私が、AIにRLSを書かせて実際に何度も見てきた「定型ミス」は、片手で数えられる程度の種類しかありません。だから答えは「AIを信用する/しない」ではなく、「どうせ同じ場所で滑る」と分かっているものを、機械的に洗い出して1件ずつ確認することです。
私はAIを敵視していません。むしろ生成の速度は最大の武器です。一人で経済産業大臣賞のB2B SaaSや本番二重課金0件の決済基盤を出せたのは、AIに実装を任せて自分は設計と検証に集中したからです。ただし、その速度を本番品質に変えるのは、生成の後ろに置いた検証ゲートです。本記事は、(1) なぜAIのRLSは同じ場所で滑るのか、(2) その定型ミスの実SQLカタログ、(3) 逆に「正しいのに紛らわしい」形、(4) これが特殊事例でない実測、(5) 生成ループへの検証の埋め込み方、までを一気に扱います。個々のパターンの深掘りは、既存のRLS正典記事に委ねます——ここで全部を再解説はしません。
なお、AIが生成したコード全般をどう診断し、本番に固めるかという買い手視点の全体像はAI生成コードの脆弱性評価ガイドとバイブコーディングの本番ハードニングガイドにまとめています。本記事は、その中でも最も漏れやすい「RLSポリシの述語」に絞った実装者向けの深掘りです。
1. なぜAIのRLSは同じ場所で滑るのか
AIが同じミスを量産するのは、注意力の問題ではありません。構造の問題です。だから注意ではなく機械で潰すのが効きます。
構造1:プロンプトの字義実装。 「ログインしたユーザーがメモを読めるようにして」と頼めば、AIも人も素直に「ログインしているか」を条件に書きます。auth.role() = 'authenticated' は、その日本語をほぼそのままSQLにした形です。「本人の行だけ」という言外の要件は、明示しない限り出力に現れません。認証(あなたは誰か)と認可(この行はあなたのものか)は日常語では地続きですが、SQLの述語では別物です。この境界そのものは「認証はするのに認可しない」RLSの正しい直し方で実SQLとともに詳説しているので、ここでは繰り返しません。
構造2:デモで絶対に露見しない。 開発中、自分は自分のアカウント1つしか使いません。自分のメモしか作っていないので、全件返ってきても「自分のメモが全部見える」だけで、正常に見えます。2つ目のアカウントで他人のIDを叩く操作を、誰もしないため、テストは緑のまま本番へ行きます。RLSの穴は「敵対的な2人目」を用意して初めて見える。AIはその2人目を勝手には用意しません。
構造3:モデルが賢くなっても変わらない。 「賢いモデルなら安全に書くのでは」という期待は裏切られます。生成コードのセキュリティ評点はモデル世代を跨いでほぼ横ばい、という調査結果があります。AIは「動くコード」の生成には極めて強いが、「壊れない構造」を保証する主体ではない。だから、賢さに賭けるのではなく、CIの品質ゲートで機械的に検証するのが唯一の再現可能な解です。
この「認可という縦のリスクが、AIにも人にも『動いたからOK』で見過ごされる」構図は、OWASP Top 10で不動の1位である A01:2021 Broken Access Control(OWASP) そのものです。RLSはこの認可をデータベース側で書く場所であり、AIが最も滑りやすい場所でもあります。
2. AIが吐くRLSの定型ミス・カタログ
ここが本記事の中身です。AI(Claude Code / Cursor / v0 / Lovable / Bolt など)が生成しがちなRLSの失敗を、実SQLで並べます。まず一覧です。
| # | 定型ミス | AIがそう書く理由 | 見抜きの一手 |
|---|---|---|---|
| 2.1 | USING (true) の全開 | 「まず動かす」を字義通りに | 所有列があるのに全開か? |
| 2.2 | auth.role() = 'authenticated' | 「ログインユーザーが読める」の直訳 | 述語が user_id を参照しているか? |
| 2.3 | WITH CHECK 欠落 | SELECTだけ考えて書き込みを忘れる | INSERT/UPDATEに対の WITH CHECK があるか? |
| 2.4 | RLS未有効化 / service_role誤用 | CREATE POLICY で満足、ENABLE を忘れる | ENABLE ROW LEVEL SECURITY があるか?鍵はどこ? |
| 2.5 | SECURITY DEFINER の search_path 未固定 | 関数を作るが権限昇格面を知らない | 定義に set search_path があるか? |
2.1 USING (true) — 全開ドア
最も直截な失敗です。「とりあえず読めるようにして」を字義通りに実装するとこうなります。
alter table public.invoices enable row level security;
-- 危険:所有列があるのに、全員に全行を開いている
create policy "read invoices" on public.invoices
for select to authenticated
using (true);
USING (true) は全行で真——invoices に user_id があっても一切参照せず、ログインユーザー全員に全請求書が返ります。ただし冒頭のFAQで書いたとおり、USING (true) は共有マスタ(国一覧・カテゴリ・公開記事)なら正当です。危険なのは「所有列があるのに全開」という組み合わせだけ。Aegisの言葉で言えば、これは「そのテーブルが公開意図なら USING (true) で構わない。所有列があるなら要確認」という縦のリスクであり、機械は「形」で警告できても「意図」までは判定できません。
2.2 auth.role() = 'authenticated' — 認証はするが認可しない
USING (true) を少しだけ賢くしたつもりの、最頻出パターンです。
-- 危険:ログイン済みかしか見ていない(=全ログインユーザーに全行)
create policy "read notes" on public.notes
for select to authenticated
using (auth.role() = 'authenticated');
auth.role() はログイン中なら常に 'authenticated'。auth.uid() is not null も同じで、セッションの存在証明にすぎず、user_id を一度も見ていません。正しい形は所有者への束縛です。
-- 安全:行を呼び出し元の所有に束縛
create policy "read notes" on public.notes
for select to authenticated
using ((select auth.uid()) = user_id);
auth.uid() を (select auth.uid()) で包むのはSupabase公式推奨の性能テクニックで、関数を行ごとに再評価せず初期プランで一度だけ評価します(Supabase Docs)。なぜこれで全行が漏れるのか、共有テーブルとの見分け方、curl での再現までは認証 vs 認可の正典記事に譲ります。この漏洩クラスの現実の重さは、AI生成プラットフォームで不十分なRLSにより任意テーブルの読み書きが可能になった CVE-2025-48757(CVSS 9.3 CRITICAL, CWE-863)が示しています。
2.3 WITH CHECK 欠落 — 読みは守れて書きが素通り
これはAIが特に踏みやすい。SELECT の USING は所有者に絞れても、書き込み側の WITH CHECK を省くのです。
-- USING は正しい。だが INSERT/UPDATE に WITH CHECK が無い
create policy "update own notes" on public.notes
for update to authenticated
using ((select auth.uid()) = user_id); -- 見える行は本人だけ
-- ← with check が無い! 書ける値に制約が無い
USING は「どの行が見えるか」、WITH CHECK は「どの値で書けるか」を制御する別の関門です(PostgreSQL Docs)。WITH CHECK を欠くと、見えている本人の行を、user_id を他人に書き換えて更新できる——所有権を渡す方向の書き込みバイパスが開きます。対で書くのが必須です。
create policy "update own notes" on public.notes
for update to authenticated
using ((select auth.uid()) = user_id)
with check ((select auth.uid()) = user_id);
USING と WITH CHECK の取り違え・欠落が具体的にどう悪用されるかは書き込みバイパスの正典記事にまとめています。
2.4 RLS未有効化 と service_role 境界の取り違え
CREATE POLICY を書いただけで満足し、ENABLE ROW LEVEL SECURITY を忘れる——ポリシは存在するのに一切適用されない状態です。
create table public.orders (id uuid primary key, user_id uuid, total int);
-- alter table ... enable row level security; ← これが無いとポリシは無力
create policy "own orders" on public.orders
for select using ((select auth.uid()) = user_id);
もう一つが鍵の取り違え。service_role キーはPostgreSQLの BYPASSRLS で動き、RLSを完全に無視します(Supabase Docs)。AIに「サーバーからデータを取って」と頼むと、安易に service_role クライアントをAPIルートやクライアント側に漏らす実装を書くことがある。そうなると守りの境界はRLSではなく鍵の置き場所に移り、RLSを何枚張っても意味がありません。この境界感覚は、コード側で所有権チェックを忘れるIDOR/壊れた認可と地続きで、RLS未有効化・USING(true)・anonへの過剰GRANTといった定番は設定ミス検出ガイドに修正SQL付きで整理しています。
2.5 SECURITY DEFINER の search_path 未固定
AIがヘルパ関数を生成するとき、SECURITY DEFINER(定義者権限で実行)を付けつつ search_path を固定しないことがあります。
-- 危険:search_path を固定していない SECURITY DEFINER
create function public.is_member(org uuid) returns boolean
language sql security definer
as $$ select exists(select 1 from memberships where org_id = org and user_id = auth.uid()) $$;
-- ← set search_path = '' が無い
SECURITY DEFINER 関数は、呼び出し元が search_path を差し替えて、攻撃者が用意した同名オブジェクトを参照させる権限昇格の入口になり得ます。定義に set search_path = ''(またはスキーマ固定)を必ず添えます。
create function public.is_member(org uuid) returns boolean
language sql security definer set search_path = ''
as $$ select exists(select 1 from public.memberships where org_id = org and user_id = (select auth.uid())) $$;
3. 「見抜けたつもり」の逆側 — AIが正しく書いた紛らわしい形
ここは誠実さのために外せません。「定型に見える=脆弱」ではない。AIの出力を機械的に叩くと、正しいのに紛らわしい形を誤検知しがちです。私が自作OSSのスキャナを実世界のリポジトリで鍛えたとき、最大の学びはここでした——素朴な検出は本物の穴の2倍以上を「穴」と報告し、その大半は次のような正しい述語でした。
-- ① service_role ゲート(バックエンド専用)— 一般ユーザーは満たせない。穴ではない
using (auth.role() = 'service_role')
-- ② 参加者束縛(DMの送受信者)— 匿名(uid=null)は満たせない。認可済み
using ((select auth.uid()) in (sender_id, receiver_id))
-- ③ 性能ラッパ付きの所有者束縛 — Supabase公式推奨。正しい
using ((select auth.uid() as uid) = user_id)
-- ④ 型キャスト付きの所有者束縛 — 正しく絞っている
using (auth.uid()::text = user_id::text)
①は service_role という制限的なゲートで、'authenticated'(本物の穴)とは正反対。③はまさにRLSの性能最適化で推奨される書き方そのものです。推奨パターンを脆弱と報告するツールは、一度で信頼を失います。 だからスキャナは「auth という単語に反応する」曖昧な検出ではなく、「穴とは何か」を肯定的に定義し、匿名が満たせない述語は抑制するよう設計しなければなりません。この誤検知クラスをどう実データで0にしたかの詳細は実態調査の手法に公開しています。
4. これは特殊事例ではない — 実測1,000アプリの下限値
「うちのAIはそこまで雑じゃない」と思うかもしれません。数字で答えます。公開Supabaseアプリ1,000件の一次調査で、RLSを持つアプリのうち約9.2%が『認証はするが行を所有者に絞らない』ポリシを持っていた(公開リポジトリ標本ゆえ下限値。所有者非スコープ=即脆弱ではなく要確認)。マイグレーションを公開GitHubにコミットしている——つまり相対的に丁寧な層——でこの水準です。母集団に入らない非公開・マイグレーション無しのアプリを含めれば、真の発生率はこれより悪い方向にしか動きません。
強調したいのは、この9.2%が「9.2%が侵害可能」ではなく「9.2%に、設計意図の確認を要するポリシがある」という意味だということ。煽らないことが数字を信頼に変えます。調査の母集団の選び方、116,662ポリシをどう解析したか、精度をどう precision 1.0 に保ったかはSupabase RLSセキュリティ実態調査にすべて公開しています。ここで再解説はしません——本記事の主張は「これはAIの標準的な出力傾向であって、例外ではない」という一点です。
5. 私の検証ワークフロー — 生成ループに検証ゲートを1枚
では実際にどう回すか。私のバイブコーディングは「生成の速度はそのまま、出荷の前に検証を1枚挟む」だけです。RLSに関しては3段構えです。
[1] 生成 Claude Code / Cursor でいつも通りポリシを書かせる
│
[2] 貼るだけ 1本のポリシを即判定したい → ブラウザRLSチェッカー
│ 正しい雛形が欲しい → RLSジェネレータ
▼
[3] 全体scan リポジトリ横断で漏れを洗う → npx @aegiskit/cli scan
│
[4] 回帰固定 直したら二度と戻さない → pgTAP で allow/deny をCIに
貼るだけ(インストール不要・SQLは外に出ない)。 生成されたポリシを1本、その場で判定したいだけなら、ブラウザ内で完結するRLSチェッカーが最速です。using (auth.role() = 'authenticated') を貼れば「認証のみ(要確認)」、using ((select auth.uid()) = user_id) を貼れば「所有者スコープ(OK)」と即座に分類します。判定は100%ブラウザ内で走り、SQLはどこにも送信しません。逆に、正しいポリシの雛形が欲しいならRLSジェネレータで、テーブルと所有列を指定して USING/WITH CHECK 対の安全な形を生成できます。
リポジトリ全体をscan。 supabase/migrations/**.sql を横断して§2の定型ミスを洗い出すなら、無料OSSのAegisで。
# インストール不要・設定不要。ローカルで走り、稼働中アプリには触れない(静的解析のみ)
npx @aegiskit/cli scan
これは実態調査で使ったのと同じ検出です。Aegisが自動化するのは「水平の統制」(ヘッダー/CSP・レート制限・CSRF・秘密情報衛生)と、RLS述語の「形」の検証まで。認可/IDOR・テナント分離という「縦のリスク」は検出・警告にとどめ、修正はあなたの設計に返します。「完全に守る」とは言いません——そう謳うツールこそ、「入れたから大丈夫」という最悪の油断を生みます。
回帰固定。 見つけて直したら、aegis fix --format json の修正計画をそのままコーディングエージェントに渡して直させ、pgTAPで allow/deny をCIに固定します。この「生成→検証→CI」の型は、AIに実装を任せつつ仕様の決定を人間が握るSpec駆動開発の一部として運用しています。
6. 自動で測れる線、測れない線
最後に、ここを崩すと全部が嘘になります。いかなる静的解析も、認可の「正しさ」を証明できません。 ツールが見るのは述語の“形”であって、あなたの業務ルールやデータモデルの“意味”ではない。
| 自動で測れる(機械化) | 人間の設計判断に残る(監査) |
|---|---|
USING (true) / auth.role()='authenticated' の検出 | そのテーブルは本当に共有意図か |
WITH CHECK 欠落・RLS未有効化 | テナント分離が設計として正しいか |
SECURITY DEFINER の search_path 未固定 | 権限昇格・業務ロジックの破れの余地 |
| 所有列 vs 述語の突き合わせ | 多段モジュールを跨ぐ認可フロー |
scanが0件でも、それは「よくある定型ミスを踏んでいない」であって「認可が正しい」ではありません。0件は出発点で、ゴールではない。「このテーブルは本当に全員に見せていいのか」の判断は、あなたのデータモデルを知る人間にしかできない。 どこまでが自動で守れ、どこからが監査の領域なのかは監査の範囲で具体的に切り分けています。
まとめ
AIが生成したRLSは信用できるか——答えは「そのまま信用してはいけないが、恐れる必要もない」です。失敗が少数の定型に収束すると分かっている以上、USING(true)・authenticated 全許可・WITH CHECK 欠落・RLS未有効化・SECURITY DEFINER の search_path 未固定を、生成のたびに機械で叩けばいい。まず貼るだけのRLSチェッカーか npx @aegiskit/cli scan で自分の出力を測ってください。
そして忘れないでほしいのは——ツールは「ここを確認せよ」と地図を描くだけで、「正しい」とは言えないということ。RLSが本当に認可になっているか、テナント分離が本当に効いているかを、データモデルを知る人間の目で確かめたいときは、既存アプリの認可レビュー・監査からご相談ください。速く作る力と、安全に作る力は、同じコインの裏表です。