# Supabase RLSの設定ミスを検出する — 未有効化・WITH CHECK欠落・USING(true)・anon過剰付与をmigrationsから洗い出す

> Supabase RLSは『有効化したつもり』で穴が開く。RLS未有効化・WITH CHECK欠落・USING(true)・anon過剰付与・search_path未固定のSECURITY DEFINERという危険パターンを、supabase/migrations/**.sqlの静的検証で洗い出して塞ぐ実践ガイドです。

- 公開日: 2026-06-28
- 著者: 友田 陽大
- タグ: Supabase, RLS, PostgreSQL, セキュリティ, アーキテクチャ設計
- URL: https://tomodahinata.com/blog/supabase-rls-misconfiguration-detection-audit-guide
- カテゴリ: アプリ層セキュリティ
- 総合ガイド: https://tomodahinata.com/blog/nextjs-supabase-application-security-guide

## 要点

- SupabaseはpublicスキーマのテーブルをPostgRESTで自動API化する。RLSを有効化していないテーブルは、ブラウザに配られるanonキーだけで誰でも全件読める——『テーブルを作っただけ』が即・全公開になる
- 危険パターンは有限で、機械的に検出できる：①RLS未有効化 ②USING(true)等の無条件許可 ③WITH CHECK欠落（読めないが書ける／所有者を書き換えられる）④anonロールへの過剰付与 ⑤search_path未固定のSECURITY DEFINER関数（RLS迂回・権限昇格）
- USINGとWITH CHECKは別の軸。USINGは『どの既存行に触れるか（読み・更新の対象）』、WITH CHECKは『書き込む新しい行の値が許されるか』。読みが完璧でも書きは無防備、という状態が普通に起きる
- これらはsupabase/migrations/**.sqlを静的に読めば洗い出せる。OSSの `npx @aegiskit/cli scan`（MIT）がこのRLS検証を行う。正しい本番RLSなら検出は0件になる、というのが設計目標
- ただし『検出』と『正しい設計』は別物。所有権モデルやテナント境界の設計は人間の判断で、ツールはポリシーの“形”しか見ない。検出0件は『よくある罠を踏んでいない』であって『認可が正しい』の証明ではない——ここは監査領域

---

最初に結論を述べます。**Supabaseの行レベルセキュリティ（RLS）は「設定したつもり」で穴が開きます。** しかも、その穴の開き方は無限のバリエーションがあるわけではなく、**有限の決まったパターン**に収束します——RLSの有効化忘れ、`USING (true)` のような無条件許可、`WITH CHECK` の欠落、`anon` ロールへの過剰な権限付与、`search_path` を固定しない `SECURITY DEFINER` 関数。この5つです。

そして重要なのは、**これらは `supabase/migrations/**.sql` を静的に読むだけで、機械的に洗い出せる**ということです。脆弱性スキャンというと「実際に攻撃して試す」イメージがあるかもしれませんが、RLSの設定ミスの大半は、SQLのDDL（テーブル・ポリシー・関数の定義）を構文的に解析した時点で「これは危ない」と判定できます。本記事は、その危険パターンのカタログと、なぜそれが量産されるのか、そしてどうやってmigrationsから自動検出するのかを、脆弱→修正の実SQLで示します。

ただし、最後に正直な線引きをします。**「検出」は機械化できても、「正しいRLS設計」は機械化できません。** 誰が何を所有し、テナントの境界をどこに引くか——これはあなたの事業ドメインに固有の設計判断であり、人間の監査領域です。ツールが言えるのは「よくある罠を踏んでいない」までで、「あなたの認可が正しい」ではない。ここを混同しないことが、この記事で一番伝えたいことです。

なお本記事は認可（authorization）まわりの一連の解説の一部です。Next.js × Supabase全体のセキュリティ像は[総合ガイド](/blog/nextjs-supabase-application-security-guide)に、鍵の取り扱いは[anonキー / service_roleキーの露出](/blog/supabase-anon-key-service-role-key-exposure-guide)に、IDキーを書き換えて他人のデータを読む[IDOR/BOLA](/blog/nextjs-supabase-idor-broken-authorization-rls-detection-guide)にそれぞれ分けてあります。本記事は「RLSポリシーそのものの設定ミス」に絞ります。

---

## 1. 前提：Supabaseは「テーブルを作る」と「APIを公開する」が同義

RLSの設定ミスがなぜ致命的なのかを理解するには、Supabaseのアーキテクチャを1つだけ押さえる必要があります。

**Supabaseは、`public` スキーマのテーブルを PostgREST 経由で自動的にREST APIとして公開します。** あなたがテーブルを1つ作った瞬間、`https://<project>.supabase.co/rest/v1/<table>` というエンドポイントが生えます。そしてこのAPIは、ブラウザに配られる **anon キー** で叩けます。anon キーは秘密情報ではなく、クライアントに埋め込まれる公開鍵です（[Supabase: API keys](https://supabase.com/docs/guides/api/api-keys)）。

つまり、こういう因果になります。

```text
CREATE TABLE しただけ        →  REST API が自動で生える
RLS を有効化していない        →  anon キーでそのAPIが素通り
anon キーは公開情報           →  実質「誰でも全件読める」
```

ここで効いてくるのがPostgreSQLの仕様です。**RLSはテーブルごとに明示的に有効化しない限り、無効です。** `CREATE TABLE` はRLSを有効化しません。`ALTER TABLE ... ENABLE ROW LEVEL SECURITY` を別途実行して初めて行制御が始まります（[PostgreSQL: Row Security Policies](https://www.postgresql.org/docs/current/ddl-rowsecurity.html)）。SupabaseのダッシュボードのTable Editorで作るとRLSがデフォルトで有効化されますが、**SQLのmigrationで `create table` を書くと有効化されない**——この非対称性が、設定ミスの最大の供給源です。

Supabase公式も「Postgres上に構築するなら、データを保護するためにすべてのテーブルでRLSを有効化すべき」と明記しています（[Supabase: Row Level Security](https://supabase.com/docs/guides/database/postgres/row-level-security)）。逆に言えば、有効化は「やってくれるもの」ではなく「あなたが書くもの」です。

この前提のもとで、危険パターンを1つずつ分解します。各パターンは「何が起きるか → 脆弱なSQL → 修正したSQL」の順で示します。

---

## 2. 危険パターン①：RLS未有効化（`ENABLE ROW LEVEL SECURITY` 忘れ）

最も多く、最も致命的なのがこれです。RLSを有効化しないままテーブルを公開してしまう。

### 何が起きるか

第1節のとおり、RLS無効のテーブルは anon キーで全件読めます。`profiles` のような個人情報テーブルがこの状態だと、氏名・メール・電話番号・決済顧客IDが、`curl` 一発で外部に流出します。

### 脆弱なSQL

```sql
-- 危険：RLS を有効化していないテーブル
create table public.profiles (
  id uuid primary key default gen_random_uuid(),
  user_id uuid references auth.users not null,
  full_name text,
  phone text,
  stripe_customer_id text
);
-- ↑ この migration には enable row level security が無い。
--   結果、次の1行で全ユーザーの個人情報が返る：
--   curl "https://<project>.supabase.co/rest/v1/profiles?select=*" \
--        -H "apikey: <anon-key>"
```

`create table` だけのmigrationは、SQLとしては完全に正しい。型もNOT NULLも外部キーも適切です。**「動くこと」と「安全であること」が完全に別問題**だと分かる典型例です。

### 修正したSQL

修正は2段階であることが重要です。**有効化（fail-secure化）→ 必要なアクセスだけ明示的に許可** の順です。

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

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

押さえるべきポイントが3つあります。

1. **RLSの有効化は fail-secure。** RLSを有効化してポリシーが1つも無ければ、デフォルトは「全拒否」です（[PostgreSQL: Row Security Policies](https://www.postgresql.org/docs/current/ddl-rowsecurity.html)）。「とりあえず `enable` だけ」しておけば、少なくとも無防備な全件公開は止まります。検出ツールが真っ先に拾うべきはこの「`enable` が存在しないテーブル」です。
2. **`auth.uid()` は `(select auth.uid())` で包む。** Supabase公式が推奨する書き方で、行ごとに関数を再評価せず初期計画でキャッシュさせるため、大きなテーブルで性能が桁違いに変わります（[Supabase: Row Level Security](https://supabase.com/docs/guides/database/postgres/row-level-security)）。正しく動くのに遅い、という罠を避けられます。
3. **テーブルの所有者はRLSをバイパスする。** PostgreSQLでは、テーブル所有者（Supabaseでは概ね `postgres`）は既定でRLSの対象外です。`ALTER TABLE ... FORCE ROW LEVEL SECURITY` を付けない限り、所有者経由のアクセスは行制御を受けません（[PostgreSQL: Row Security Policies](https://www.postgresql.org/docs/current/ddl-rowsecurity.html)）。これは後述の `SECURITY DEFINER` 関数（パターン⑤）の落とし穴に直結します。

---

## 3. 危険パターン②：`USING (true)` / 無条件ポリシー

RLSを有効化し、ポリシーも書いた。だが、その条件が「常に真」になっている——これがパターン②です。

### 何が起きるか

`using (true)` は「すべての行を可視にする」という意味です。RLSを有効化した安心感のなかで、実質的に「全拒否」を「全許可」へ戻してしまう。`alter table ... enable row level security` のログだけ見ると守っているように見えるのに、中身が素通しという、検出が最も価値を持つケースです。

### 脆弱なSQL

```sql
alter table public.documents enable row level security;

-- 危険：条件が true ＝全行を無条件に許可（RLSを有効化した意味が消える）
create policy "anyone can read documents"
on public.documents
for select
using ( true );
```

さらに見落とされがちなのが **`TO` 句の省略** です。`TO` を書かないポリシーは `PUBLIC`、つまり `anon` を含む全ロールに適用されます（[PostgreSQL: CREATE POLICY](https://www.postgresql.org/docs/current/sql-createpolicy.html)）。上のポリシーは `to authenticated` すら無いので、**未ログインの anon でも全 `documents` が読めます。** `using (true)` と `TO` 省略のコンボは、機微テーブルの全公開と同義です。

### 修正したSQL

```sql
-- 所有者の行だけを、ログイン済みユーザーに対して許可する
create policy "owners read their documents"
on public.documents
for select
to authenticated
using ( (select auth.uid()) = owner_id );
```

ここで正直に線を引きます。**`using (true)` は必ずしもバグではありません。** ブログ記事の公開テーブルや、マスタデータのように「本当に全員が読んでよい」テーブルなら、`to anon using (true)` は正しい設計です。だから検出ツールは `using (true)` を**「即・脆弱」と断定するのではなく、「無条件許可。本当に公開意図か？」と人間に確認を促す警告**として出すのが正しい振る舞いです。機械が判定できるのは「無条件である」という構文事実までで、「公開してよいデータか」はドメイン知識です。この線引きは本記事の後半でもう一度強調します。

---

## 4. 危険パターン③：`WITH CHECK` 欠落 — 「読めないが書ける」を作る

ここが、RLSで最も誤解されているポイントです。**読み取りは完璧にロックされているのに、書き込みは無防備**、という状態が普通に発生します。原因は `USING` と `WITH CHECK` の役割を混同することです。

### `USING` と `WITH CHECK` は別の軸

PostgreSQLのポリシーには2種類の式があります（[PostgreSQL: CREATE POLICY](https://www.postgresql.org/docs/current/sql-createpolicy.html)）。

| 式 | 何を検査するか | 効くコマンド |
|---|---|---|
| `USING` | **既存の行**。どの行が見えるか／更新・削除の対象になれるか | SELECT / UPDATE / DELETE |
| `WITH CHECK` | **新しい行の値**。INSERT・UPDATEで書き込もうとする行が許されるか | INSERT / UPDATE |

決定的に重要なのは、**INSERT には `USING` が存在しない**ことです。新規行には「既存の行」が無いので、INSERT を縛れるのは `WITH CHECK` だけ。したがって、`WITH CHECK` を書き忘れた／`true` にした INSERT ポリシーは、**任意の値での書き込みを素通し**します。

### 何が起きるか

具体的な被害は2つです。

1. **なりすまし挿入**：ユーザーが `user_id` を他人のIDにしてINSERTし、他人名義のデータを植え付ける／他テナントを汚染する。
2. **所有権の書き換え**：UPDATEで自分の行の `owner_id` を他人に書き換える、あるいは `role` を `admin` に昇格させる。

### 脆弱なSQL

```sql
alter table public.documents enable row level security;

-- 読みは「自分の行だけ」——ここは完璧に見える
create policy "read own"
on public.documents
for select
to authenticated
using ( (select auth.uid()) = owner_id );

-- 危険①：INSERT の検査が true ＝任意の owner_id で行を作れる（なりすまし挿入）
create policy "insert any"
on public.documents
for insert
to authenticated
with check ( true );

-- 危険②：UPDATE は対象行を自分に絞っているが、新しい行の値を検査しない
--   → 自分の行の owner_id を他人に書き換えられる（所有権の移譲・剥奪）
create policy "update own"
on public.documents
for update
to authenticated
using ( (select auth.uid()) = owner_id )
with check ( true );
```

この設定の恐ろしさは、**SELECTポリシーが完璧なので「RLSは効いている」と錯覚する**ことです。読み取りテストでは他人のデータが見えないので、レビューを通ってしまう。穴は書き込み側に開いています。

### 修正したSQL

書き込み系のポリシーでは、`WITH CHECK` に**新しい行の所有者が自分であること**を必ず要求します。

```sql
-- INSERT：作る行の owner_id は必ず自分
create policy "insert own"
on public.documents
for insert
to authenticated
with check ( (select auth.uid()) = owner_id );

-- UPDATE：対象も新しい行も自分に限定（owner_id の書き換えを封じる）
create policy "update own"
on public.documents
for update
to authenticated
using ( (select auth.uid()) = owner_id )
with check ( (select auth.uid()) = owner_id );
```

### 検出側の注意：`WITH CHECK` 省略は「常にバグ」ではない

正確を期すと、`FOR ALL` や `FOR UPDATE` のポリシーで `WITH CHECK` を**省略**した場合、PostgreSQLは `USING` の式を `WITH CHECK` にも流用します（[PostgreSQL: CREATE POLICY](https://www.postgresql.org/docs/current/sql-createpolicy.html)）。つまり次は安全です。

```sql
-- USING がスコープされ、WITH CHECK 省略 → USING が流用されるので書き込みも守られる
create policy "manage own"
on public.documents
for all
to authenticated
using ( (select auth.uid()) = owner_id );
```

したがって検出ツールが本当に拾うべきは「省略」ではなく、**(a) `FOR INSERT` で `WITH CHECK` が無い／`true`、(b) どのコマンドであれ `WITH CHECK ( true )` と明示している、(c) そもそも `USING` 側が `using (true)` で流用しても無意味**——この3つです。「省略＝即バグ」と機械的に鳴らすと誤検知（ノイズ）が増え、ツールが信用されなくなります。RLS検証の質は、この種の挙動差をどれだけ正確にモデル化できるかで決まります。

---

## 5. 危険パターン④：`anon` ロールへの過剰なGRANT/ポリシー

Supabaseでは、JWTに応じて2つのデータベースロールが使い分けられます。未ログインの **`anon`** と、ログイン済みの **`authenticated`** です。PostgRESTはこのロールでクエリを実行し、RLSはそのロールに対して評価されます。

### 何が起きるか

`anon` は「インターネット全体」だと思ってください。`anon` に機微テーブルへのアクセスが渡ると、認証すら不要で漏れます。過剰付与には2つの経路があります。

1. **ポリシーで `anon` を対象にする**（`to anon`、または `TO` 省略で `PUBLIC` に含めてしまう）
2. **テーブル権限を `anon` にGRANTする**（`grant select on ... to anon`）

### 脆弱なSQL

```sql
alter table public.orders enable row level security;

-- 危険①：anon に対して全件可視
create policy "public read orders"
on public.orders
for select
to anon
using ( true );

-- 危険②：TO 省略 → PUBLIC（anon を含む）に適用される
--   作者は「ログイン済み向け」のつもりでも、anon にも開いてしまう
create policy "read orders"
on public.orders
for select
using ( (select auth.uid()) = customer_id );
-- ↑ anon は auth.uid() が NULL なので結果的に0件だが、
--   ポリシーの対象に anon が含まれること自体が設計意図のズレで、
--   using を true 系に書き換えた瞬間に全公開へ反転する地雷になる
```

加えて、テーブルそのものの権限付与も見ます。

```sql
-- 危険：anon に直接 SELECT 権限を与える（RLS が無効だと即・全公開）
grant select on public.orders to anon;
```

### 修正したSQL

機微テーブルは原則 `authenticated` 起点に限定し、`anon` への露出は「本当に公開してよいデータ」だけに絞ります。

```sql
-- ポリシーは authenticated を明示し、所有権で絞る
create policy "customers read own orders"
on public.orders
for select
to authenticated
using ( (select auth.uid()) = customer_id );

-- anon に渡した過剰な権限は剥がす
revoke select on public.orders from anon;
```

検出の観点では、「`to anon`／`TO` 省略のポリシー」と「`anon` への `GRANT`」を機微テーブルに対して洗い出し、**公開意図が明示されていないものを警告**します。anon キー自体は「RLSと併用される前提で公開してよい鍵」ですが（[Supabase: API keys](https://supabase.com/docs/guides/api/api-keys)）、それは**RLSが正しく効いていれば**の話です。鍵の安全な置き場所そのものについては[anon/service_roleキーの露出](/blog/supabase-anon-key-service-role-key-exposure-guide)で詳述しています。

---

## 6. 危険パターン⑤：`search_path` 未固定の `SECURITY DEFINER` 関数

最後は、RLSの「外側」から穴を開けるパターンです。RPC（リモート関数）として公開されるPostgreSQL関数に潜みます。

### 何が起きるか

`SECURITY DEFINER` を付けた関数は、**呼び出し元ではなく関数の所有者の権限で実行**されます（[PostgreSQL: CREATE FUNCTION](https://www.postgresql.org/docs/current/sql-createfunction.html)）。Supabaseでは関数の所有者は概ね `postgres`——つまり**テーブル所有者であり、RLSをバイパスする**ロールです（第2節の3点目）。したがって、`SECURITY DEFINER` 関数の中のクエリは、**RLSを丸ごと無視**します。これをPostgREST経由でanon/authenticatedに公開し、かつ関数内で所有権チェックをしていなければ、RLSをいくら張っても素通りされます。

さらに `search_path` を固定していないと、もう一段危険です。PostgreSQL公式は「`SECURITY DEFINER` 関数は、信頼できないユーザーが先に検索されるスキーマにオブジェクトを作れると乗っ取られうるため、`search_path` を安全な値に固定せよ」と明確に警告しています（[PostgreSQL: CREATE FUNCTION](https://www.postgresql.org/docs/current/sql-createfunction.html)）。非修飾名（`profiles` のようにスキーマを付けない参照）が、攻撃者の用意した別オブジェクトに解決され、所有者権限で実行される——権限昇格の温床です。

### 脆弱なSQL

```sql
-- 危険：所有者権限で動くのに、search_path 未固定・所有権チェック無し・anon に開放
create or replace function public.delete_account(target uuid)
returns void
language plpgsql
security definer                       -- postgres 権限で実行＝RLSバイパス
as $$
begin
  delete from profiles                 -- 非修飾名（search_path 次第で別物に解決されうる）
  where id = target;                   -- 呼び出し元が誰かを一切問わない
end;
$$;

-- PostgREST 経由で誰でも呼べてしまう
grant execute on function public.delete_account(uuid) to anon, authenticated;
-- 結果：任意の target を渡せば、他人のアカウントを削除できる
```

この関数は、RLSが完璧に張られた `profiles` に対しても、**所有者権限で動くため全行を削除できます。** RLSは関係ありません。

### 修正したSQL

`SECURITY DEFINER` を使うなら、(1) `search_path` を固定、(2) オブジェクトは完全修飾、(3) 関数内で呼び出し元の本人性を強制、(4) `anon` には渡さない——の4点を徹底します。

```sql
create or replace function public.delete_my_account()
returns void
language plpgsql
security definer
set search_path = ''                   -- search_path を固定し、非修飾名の乗っ取りを封じる
as $$
begin
  delete from public.profiles          -- 完全修飾名で参照する
  where id = (select auth.uid());      -- 引数を信じず、呼び出し元本人に限定
end;
$$;

-- 公開範囲を最小化：PUBLIC から剥がし、ログイン済みだけに付与
revoke all on function public.delete_my_account() from public;
grant execute on function public.delete_my_account() to authenticated;
```

検出の観点はシンプルです。**`security definer` を持つのに `set search_path` が無い関数**を全件挙げ、加えて `anon` への `grant execute` を警告する。前者は構文だけで確実に拾えます（`search_path` の有無は機械判定できる）。後者の「関数内で本人性を強制しているか」は、関数本体のロジックを読む必要があり、ここは半ば人間の領域です。

---

## 7. なぜAI生成コードと急いだ開発で量産されるのか

ここまでの5パターンは、知っていれば避けられます。にもかかわらず量産されるのはなぜか。理由は構造的です。

**RLSの設定ミスは、デモでは絶対に顕在化しません。** 自分のアカウント1つで触っている限り、`using (true)` でも所有権チェック付きでも、画面の見た目は同じです。`WITH CHECK` が無くても、自分の行を自分で更新する分には動く。**「動いた＝正しい」の罠**が、認可では特に深い。バグが見えるのは「他人のアカウントが存在し、その境界を踏み越えたとき」だけで、それは開発中にまず起きません。

AIコード生成は、この傾向を増幅します。AIはプロンプトの「やりたいこと（=デモで動くこと）」を最短で実現するコードを書きます。「他人のデータを見せない」「他人になりすませない」は、明示的に要求しない限りハッピーパスの外側で、出力されないか、`using (true)` のような“とりあえず通る”形になりがちです。

これは抽象論ではなく、現実のインシデントとして記録されています。2025年に登録された **[CVE-2025-48757](https://nvd.nist.gov/vuln/detail/CVE-2025-48757)** は、その典型です。NVDの記述によれば、AIアプリ生成プラットフォーム Lovable（2025-04-15まで）の**不十分なRow-Level Securityポリシー**により、リモートの**未認証**攻撃者が、生成されたサイトの任意のDBテーブルを読み書きできました。分類は **CWE-863（Incorrect Authorization、不正な認可）**、CVSS基本値は **9.3 CRITICAL**。本記事のパターン①（RLS未有効化）と④（anonへの露出）が、実際に大規模に起きたという証拠です。

そしてこの設定ミスがもたらす被害の典型が、認可の失敗——OWASPがAPIリスクの第1位に挙げる **[API1:2023 Broken Object Level Authorization（BOLA）](https://owasp.org/API-Security/editions/2023/en/0xa1-broken-object-level-authorization/)** です。RLSはこのBOLAを「DB層で」防ぐための主要な防御線であり、その設定が崩れることは、最頻出のAPIリスクに直結します。

要するに、**RLSの設定ミスは「珍しい高度なバグ」ではなく、速さを優先する現代の開発で構造的に生まれる“普通の事故”**です。だからこそ、人間のレビューに加えて、機械的な検出をパイプラインに組み込む価値があります。

---

## 8. 検出の自動化：migrationsの静的検証という考え方

5パターンに共通するのは、**すべて `supabase/migrations/**.sql` のDDLに痕跡が残る**ことです。実際に攻撃しなくても、SQLを読めば判定できる。これが「migrationsの静的検証」です。

### 単純なgrepでは不十分

注意すべきは、**単一ファイルへの `grep` では誤判定する**ことです。RLSの有効化は、テーブル作成とは別の、後続のmigrationで行われることがあります。`enable row level security` を「あるファイルに無いから危険」と判定すると、別ファイルで有効化しているケースを誤検知します。正しくは、**全migrationを時系列で畳み込み、各テーブルの“最終状態”を再構成**する必要があります。

### 概念モデル

考え方を擬似コードで示します。migrationsを畳み込んで、テーブルごとの状態モデルを作り、危険パターンを照合します。

```ts
// migrations を時系列で畳み込んで得る「最終状態」のモデル
type PolicyModel = {
  table: string;
  command: "select" | "insert" | "update" | "delete" | "all";
  roles: string[];          // 空配列 = TO 省略 = PUBLIC（anon を含む）
  using: string | null;     // 既存行に対する条件
  withCheck: string | null; // 新しい行に対する条件
};

type TableModel = {
  schema: string;
  name: string;
  rlsEnabled: boolean;      // ALTER TABLE ... ENABLE ROW LEVEL SECURITY を畳み込んだ結果
  policies: PolicyModel[];
};

const isTrue = (e: string | null) => e !== null && /^\s*true\s*$/i.test(e);
const facesAnon = (p: PolicyModel) => p.roles.length === 0 || p.roles.includes("anon");

function findings(t: TableModel): string[] {
  const out: string[] = [];

  // ① RLS未有効化（PostgREST に露出する public スキーマ）
  if (t.schema === "public" && !t.rlsEnabled)
    out.push("RLS未有効化：anon キーで全件読める可能性");

  for (const p of t.policies) {
    // ② USING(true) かつ anon/PUBLIC 向け → 無条件公開（要・公開意図の確認）
    if (isTrue(p.using) && facesAnon(p))
      out.push(`USING(true)：${p.command} を無条件許可（本当に公開？）`);

    // ③ INSERT は USING を持たない＝WITH CHECK だけが頼り
    if (p.command === "insert" && (p.withCheck === null || isTrue(p.withCheck)))
      out.push("INSERT が無検査：任意の owner_id で行を作れる");
    // 明示的な WITH CHECK(true) は、どのコマンドでも新しい行を検査しない
    if (isTrue(p.withCheck))
      out.push(`WITH CHECK(true)：${p.command} で所有者の書き換え/なりすまし可能`);

    // ④ anon / PUBLIC 向けポリシー（公開意図が明示されているか要確認）
    if (facesAnon(p) && !isTrue(p.using))
      out.push(`anon/PUBLIC 対象：${p.command} の露出範囲を確認`);
  }
  return out;
}
```

これはあくまで骨子です。実際の検出では、第4節で述べた「`FOR ALL`/`FOR UPDATE` は `WITH CHECK` 省略時に `USING` を流用する」という挙動差を考慮して誤検知を減らし、`SECURITY DEFINER` 関数の `search_path` 有無や `anon` への `GRANT` も併せて解析します。

### OSSで実行する

この検出を、設定不要で実行できるOSSが **Aegis**（MITライセンス）です。`supabase/migrations` を解析して、上記カタログ（①〜⑤）を照合します。

```bash
# インストール不要・設定不要。プロジェクト直下で実行する
npx @aegiskit/cli scan
```

過大な主張はしません。これは「DDLに対するヒューリスティックな静的解析」であって、それ以上でも以下でもありません。正直なスコープは後述の第11節で線を引きます。RLSの設計そのもの——たとえば[本番マルチテナンシーのパターン](/blog/supabase-rls-production-multi-tenancy-patterns)や、ポリシーの回帰を防ぐ[pgTAPでのテスト](/blog/supabase-rls-testing-pgtap-policy-regression-guide)、そして[テナント越境の実地検証](/blog/supabase-multi-tenant-cross-tenant-leak-verification-guide)は、別記事で扱っています。

---

## 9. 「検出」できても「正しい設計」は別問題

ここが本記事で最も大切な節です。前節までで「危険パターンは機械検出できる」と述べました。では、検出が0件になればRLSは「正しい」のでしょうか。**いいえ。**

検出ツールが見ているのは、ポリシーや関数の **“形（構文・構造）”** です。あなたの事業ルールやデータモデルの **“意味”** ではありません。具体的に、ツールには原理的に分からないことがあります。

- **所有権モデル**：このテーブルの“持ち主”を表す列は `owner_id` なのか `user_id` なのか `created_by` なのか。`auth.uid()` と突き合わせるべき列が、本当にその列で正しいのか。
- **テナント境界**：マルチテナントで、行は `organization_id` で隔離すべきなのに `user_id` で隔離していないか。組織のメンバー全員が見るべき行を、作成者本人しか見られなくなっていないか（あるいはその逆）。
- **業務ルール**：「下書きは本人だけ、公開済みは全員」のような状態に応じた可視性が、ポリシーに正しく反映されているか。
- **ロールの設計**：`admin` は何を越えてよいのか。その“越境”は意図された権限か、それともバグか。

`(select auth.uid()) = user_id` という構文的に完璧なポリシーが、**ドメイン的には間違っている**ことは普通にあります。たとえば本来は組織単位で共有すべきデータを個人単位に閉じてしまえば、検出は0件でも、要件を満たさない（または別の場所で `using (true)` に“回避”されて穴が開く）。

だから、**正しい本番RLSの設計目標は「検出が0件になること」**です——これは必要条件であって、十分条件ではありません。検出0件は「よくある罠を踏んでいない」ことの証明であり、「認可が正しい」ことの証明ではない。後者は、所有権モデルとテナント境界を人間が設計し、レビューし、テナント越境の検証やpgTAPの回帰テストで“確定”させて初めて言えます。

テナント境界の設計が事業の生命線になるB2B SaaS——たとえば私が携わった[木材流通DXの事例](/case-studies/lumber-industry-dx)のようなマルチテナント基盤——では、「誰がどのテナントの何を所有するか」の線引きそのものが認可設計の本体でした。ツールはその線引きを**速く・漏れなく検証する道具**であって、線引きそのものを代行はできません。

---

## 10. 本番投入前のRLSチェックリスト

外注でもAI生成でも、本番に出す前に最低限これだけは確かめてください。すべて `supabase/migrations/**.sql` を読めば判定できます。

| 観点 | 確認すること | 危険信号 |
|---|---|---|
| **RLS有効化** | `public` の全テーブルに `enable row level security` があるか | `create table` だけで `enable` が無い |
| **デフォルト拒否** | 有効化したテーブルに、必要なポリシーが揃っているか | 有効化したがポリシー0個で機能が壊れ、`using(true)` で“回避”されている |
| **無条件許可** | `using (true)` / `with check (true)` の公開意図が明確か | 機微テーブルに無条件ポリシーがある |
| **WITH CHECK** | INSERT/UPDATEで新しい行の所有者を検査しているか | `for insert` に `with check` が無い／`true` |
| **TO 句** | ポリシーが `to authenticated` 等で対象ロールを明示しているか | `TO` 省略で `anon` まで開いている |
| **anon権限** | 機微テーブルへの `grant ... to anon` が無いか | 個人情報テーブルが anon にGRANTされている |
| **SECURITY DEFINER** | `set search_path` を固定し、本人性を関数内で強制しているか | `search_path` 未固定／`anon` に `execute` 付与 |
| **検証の自動化** | スキャン・回帰テストがCIに組み込まれているか | 「手元で動いた」以外の根拠が無い |

発注者の視点で最も効く質問は2つです。**「未ログイン（anon）で叩いたら、どのテーブルが見えますか？」「`security definer` 関数はいくつあって、それぞれ search_path を固定していますか？」**——明確に即答できないなら、RLSの検証が設計に組み込まれていない可能性が高い。

---

## 11. 自分でやる範囲と、監査に任せる範囲（正直に）

最後に、現実的な線引きを示します。何を自分でやり、どこから専門家に任せるべきか。

**自分でやるべき（＝OSSで十分カバーできる）範囲：**

- パターン①〜⑤の**機械検出**。`npx @aegiskit/cli scan` をCIに入れ、migrationのたびに自動で回す。これは無料で、今日からできます。
- チェックリストの構文レベルの確認。`enable` の有無、`with check` の有無、`search_path` の有無は、人間がSQLを読んでも拾えます。
- pgTAPでの基本的な回帰テスト。「他人の行が見えない／書けない」を1本書いておく。

**監査に任せるべき（＝人間の設計判断が要る）範囲：**

- **所有権モデルとテナント境界の設計レビュー**。`auth.uid()` と突き合わせる列が正しいか、組織単位の共有が要件どおりか——ここはあなたの事業を理解した上での判断です。
- **検出0件の“その先”**。検出ツールがクリーンでも、ドメイン的に穴がないかは別問題。脅威モデリングと、実際に別テナントになりすまして叩く実地検証が必要です。
- **既存アプリの認可全体の棚卸し**。RLSだけでなく、service_role経路・RPC・Storage・外部結合先まで含めた横断レビュー。

この線引きに沿って、OSSの[Aegis](/aegis)が「検出」を、[セキュリティ監査](/aegis/audit)が「設計・実装で実際に塞ぐ」ところを担います。前者は無料で、後者はスポット診断や標準監査として提供しています。**「ツールを入れたから安全」とは口が裂けても言いません**——その油断こそが最悪の結果を生むからです。ツールは人間の判断を**置き換えるのではなく、補完する**。最頻出の罠を機械的に潰し、人間が本当に難しい設計判断に集中できるようにする。それが正しい使い方です。

---

## よくある質問（FAQ）

**Q. 全テーブルにRLSを有効化すれば、設定ミスは防げますか？**
A. パターン①は防げますが、②〜⑤は防げません。`using (true)` で素通しになる、`WITH CHECK` が無くて書き込みが無防備、`SECURITY DEFINER` 関数でRLSを飛び越える——有効化は出発点であって、ゴールではありません。

**Q. `using (true)` は常にバグですか？**
A. いいえ。本当に公開してよいデータ（公開記事、マスタデータ等）なら正しい設計です。だから検出は「無条件許可。公開意図か？」という確認の警告として扱うべきで、機械が「即バグ」と断定するのは誤りです。公開意図かどうかはドメイン知識です。

**Q. `USING` だけ書いておけば、書き込みも守られますか？**
A. コマンド次第です。`FOR ALL`/`FOR UPDATE` は `WITH CHECK` 省略時に `USING` を流用するので守られますが、**`FOR INSERT` には `USING` が無い**ため、`WITH CHECK` を書かないと無検査になります。読みと書きは別の軸だと考えてください。

**Q. `SECURITY DEFINER` 関数は使ってはいけませんか？**
A. 正当な用途があります（RLSを越える集計や管理処理など）。鉄則は、`set search_path` を固定し、オブジェクトを完全修飾し、関数内で本人性を強制し、`anon` には `execute` を渡さないこと（[PostgreSQL: CREATE FUNCTION](https://www.postgresql.org/docs/current/sql-createfunction.html)）。これらを守れない関数は公開しないでください。

**Q. 検出が0件なら、RLSは正しいと言えますか？**
A. 言えません。検出0件は「よくある罠を踏んでいない」ことの証明で、「認可が正しい」ことの証明ではありません。所有権モデルとテナント境界が要件どおりかは、人間の設計判断と実地検証で確かめる必要があります。第9節のとおり、0件は必要条件であって十分条件ではありません。

---

## まとめ：危険パターンは有限、検出は機械化できる。設計は人間がやる

要点を整理します。

- Supabaseは `public` のテーブルを自動でAPI化する。**RLS未有効化は「テーブルを作っただけ」で全公開**になり、これがCVE-2025-48757で実際に起きたことです。
- RLSの設定ミスは無限ではなく、**5つの有限パターン**——①未有効化 ②`using (true)`等の無条件許可 ③`WITH CHECK`欠落 ④`anon`への過剰付与 ⑤`search_path`未固定の`SECURITY DEFINER`——に収束します。
- `USING`（既存の行）と`WITH CHECK`（新しい行の値）は**別の軸**。読みが完璧でも書きは無防備、という状態が普通に起きます。
- これらは `supabase/migrations/**.sql` を時系列で畳み込んで静的検証すれば**機械的に洗い出せます**。OSSの `npx @aegiskit/cli scan`（MIT）がこれを行います。
- ただし**「検出」と「正しい設計」は別物**。所有権モデルとテナント境界の設計は人間の判断で、検出0件は「罠を踏んでいない」であって「認可が正しい」の証明ではありません。

AIで速く作ること自体は正しい。問題は、速く作ったものの**認可を、漏らさず検証する仕組みが無い**ことです。危険パターンの機械検出は無料のOSS Aegis（`npx @aegiskit/cli scan`）で今日から始められ、その先の——所有権モデルやテナント境界まで踏み込んだ設計レビューや、既存のSupabaseアプリのRLS棚卸しは、前節で触れたセキュリティ監査でお手伝いします。お気軽にご相談ください。

---

## 参考資料

- [Supabase Docs — Row Level Security（全テーブルでRLSを有効化すべき／service_roleはRLSをバイパス）](https://supabase.com/docs/guides/database/postgres/row-level-security)
- [Supabase Docs — API keys（anonキーは公開鍵、RLS併用が前提）](https://supabase.com/docs/guides/api/api-keys)
- [PostgreSQL — Row Security Policies（RLSは既定で無効・テーブルごとに有効化／所有者はバイパス）](https://www.postgresql.org/docs/current/ddl-rowsecurity.html)
- [PostgreSQL — CREATE POLICY（USING と WITH CHECK の違い／TO 省略は PUBLIC）](https://www.postgresql.org/docs/current/sql-createpolicy.html)
- [PostgreSQL — CREATE FUNCTION（SECURITY DEFINER と search_path 固定の警告）](https://www.postgresql.org/docs/current/sql-createfunction.html)
- [NVD — CVE-2025-48757（不十分なRLSによる未認証の読み書き、CWE-863、CVSS 9.3）](https://nvd.nist.gov/vuln/detail/CVE-2025-48757)
- [OWASP API Security Top 10 — API1:2023 Broken Object Level Authorization](https://owasp.org/API-Security/editions/2023/en/0xa1-broken-object-level-authorization/)
