# Supabase RLSが効かない・空が返る・INSERTが弾かれる：原因別トラブルシューティング完全ガイド

> Supabase（PostgreSQL）の行レベルセキュリティ(RLS)でよく遭遇する3大症状——『SELECTが空で返る』『new row violates row-level security policyでINSERTが弾かれる』『RLSが効かずデータが漏れる』——を、原因の切り分けフローと診断SQL（pg_policies・relrowsecurity・auth.uid()・set local role）で体系的にデバッグします。公式準拠で、推測ではなく証拠で直す手順。

- 公開日: 2026-06-28
- 著者: 友田 陽大
- タグ: Supabase, RLS, PostgreSQL, セキュリティ, テスト
- URL: https://tomodahinata.com/blog/supabase-rls-troubleshooting-empty-results-insert-violation-not-working-guide
- カテゴリ: データベース・RLS
- 総合ガイド: https://tomodahinata.com/blog/supabase-production-guide-nextjs-rls-realtime-edge-functions

## 要点

- RLSのデバッグは推測でポリシーをいじらない。relrowsecurity/pg_policies/auth.uid()/set local roleの4つの診断で『どこで止まっているか』を証拠で特定してから直す
- 『SELECTが空』の最頻原因は4つ——RLS有効化済みでポリシー不在（デフォルト拒否）、未認証でauth.uid()がnull、GRANT不足、USINGの述語不一致。set local roleで認証済みになりきって切り分ける
- 『new row violates row-level security policy（42501）』はINSERT/UPDATEのWITH CHECK違反。挿す行がポリシー述語を満たしていない（典型はuser_id未設定や他テナントのid差し込み）
- 『RLSが効かず漏れる』はenable忘れ、service_role接続、テーブル所有者バイパス（FORCE未設定）、SECURITY DEFINER関数やビューのバイパスが典型。relrowsecurity=falseをまず疑う
- 直したら必ずpgTAPで許可と拒否の両方を再検証する。トラブルシュートで述語を緩めて『動いたが漏れる』退行を作らない

---

RLS（行レベルセキュリティ）のデバッグで最もやってはいけないのは、**「とりあえずポリシーを `using (true)` にして動くか試す」**ことです。それは「鍵が開かないから玄関を外した」のと同じで、動いたように見えて**全データを公開**しています。トラブルシューティングの原則は一つ——**推測でポリシーをいじらず、まず『どこで止まっているか』を証拠で特定する**。

この記事は、Supabase + PostgreSQL の RLS で遭遇する**3大症状**を、原因別に切り分けて直す実践ガイドです。

1. **SELECT が空で返る**（読めるはずが0件）
2. **`new row violates row-level security policy` で INSERT が弾かれる**
3. **RLS が効かず、データが漏れる**（見えてはいけない行が見える）

題材は[リアルタイム試合記録アプリ](/case-studies/realtime-sports-scoring-app)（69テーブル全RLS・280ポリシー）の運用知見。内容は[Supabase公式](https://supabase.com/docs/guides/database/postgres/row-level-security)・[PostgreSQL公式](https://www.postgresql.org/docs/current/ddl-rowsecurity.html)（2026年6月時点）に忠実です。

> RLSの前提（有効化・USING/WITH CHECK・ロール）は[RLS入門](/blog/supabase-rls-getting-started-enable-first-policy-guide)、Next.js特有の「クライアント生成ミスで空が返る」は[App Router統合ガイド](/blog/nextjs-app-router-supabase-rls-ssr-server-client-auth-guide)で扱っています。本記事はDB側の切り分けに集中します。

---

## 0. まず手に入れる：4つの診断クエリ

症状を見る前に、**証拠を集める道具**を用意します。この4つで「RLSの状態」が丸裸になります。

### 診断1：RLSは有効か？所有者バイパスは？

```sql
select relname,
       relrowsecurity   as rls_enabled,   -- enable row level security 済みか
       relforcerowsecurity as rls_forced  -- 所有者にも強制しているか
from pg_class
where oid = 'public.matches'::regclass;
```

- `rls_enabled = false` なら、**RLSはそもそも効いていません**（症状3の最頻原因）。
- `rls_enabled = true, rls_forced = false` なら、テーブル所有者として繋ぐと**バイパス**されます。

### 診断2：どんなポリシーが付いているか？

```sql
select policyname, cmd, roles, qual as using_expr, with_check as check_expr
from pg_policies
where schemaname = 'public' and tablename = 'matches';
```

`qual`（USING）と `with_check`（WITH CHECK）の中身、対象 `cmd`（SELECT/INSERT/...）、`roles`（TO句）が一覧で見えます。**「ポリシーが0件」ならデフォルト拒否**で全て弾かれます。

### 診断3：いま自分は誰か？auth.uid()は何を返すか？

```sql
select current_user,                          -- 接続ロール（anon/authenticated/...）
       auth.uid()       as uid,               -- ユーザーID（未認証ならnull）
       auth.jwt()->>'role' as jwt_role;        -- JWTのrole
```

`uid` が `null` なら**未認証扱い**で、`(select auth.uid()) = user_id` は永遠に偽になります。

### 診断4：認証済みユーザーになりきって再現する

ダッシュボードのSQLエディタは通常 `service_role`（RLSバイパス）で動くため、**バグを再現できません**。特定ユーザーに偽装します。

```sql
begin;
  set local role authenticated;
  set local request.jwt.claims =
    '{"sub":"00000000-0000-0000-0000-000000000001","role":"authenticated"}';

  -- ここで本番と同じクエリを投げ、症状を再現する
  select * from public.matches;
rollback; -- 状態を変えない（冪等）
```

この4つが揃えば、あとは症状ごとに「どの診断が異常か」を見るだけです。

---

## 1. 症状A：SELECTが空で返る（読めるはずなのに0件）

最頻の悩みです。原因は次の4つに収束します。診断1〜4で順に潰します。

| 原因 | 診断での見え方 | 直し方 |
| --- | --- | --- |
| **ポリシー不在**（有効化だけした） | 診断2が0件 | SELECTポリシーを1本書く |
| **未認証**（JWTが届いていない） | 診断3で `uid` が `null` | 認証経路を直す（Next.jsは[統合ガイド](/blog/nextjs-app-router-supabase-rls-ssr-server-client-auth-guide)） |
| **GRANT不足** | `permission denied for table` エラー | `grant select ... to authenticated` |
| **USINGの述語不一致** | 診断4でも0件 | 述語の列・型・値を実データと突き合わせる |

### 切り分けの順番

1. **診断2でポリシーを見る。** 0件なら答えは出た——`enable` したのにSELECTポリシーが無く、デフォルト拒否で全部消えている。
2. **診断3で `uid` を見る。** `null` なら未認証。アプリ側はトークンを送れているか（Next.jsならクライアント生成・middleware）。
3. **診断4で認証済みになりきる。** ここで**正しく行が返るなら、問題はDBではなくアプリの認証経路**。逆に診断4でも空なら、ポリシーの述語が間違っている。
4. 述語不一致の典型：`user_id` に入っているのが `auth.users.id` ではなく別IDだった、型が `text` vs `uuid` でキャストが必要だった、`tenant_id` の値がずれていた——**実データを `select user_id from matches limit 5` で見て、`auth.uid()` の値と照合**します。

> **やってはいけない切り分け**：`service_role` で繋いで「データはある」と確認して満足すること。`service_role` はRLSをバイパスするので「データの存在」しか分からず、**RLSの問題は何一つ切り分けられません**。必ず診断4で `authenticated` として再現してください。

---

## 2. 症状B：`new row violates row-level security policy`

INSERT/UPDATE時のこのエラー（SQLSTATE **42501**）は、**`WITH CHECK` 違反**です。意味は明確——「**作ろうとしている新しい行が、書き込みポリシーの述語を満たしていない**」。

PostgreSQL公式の定義通り、`WITH CHECK` は行の*元の内容*ではなく*提案された新しい内容*に対して評価され、偽なら弾かれます（[PostgreSQL公式](https://www.postgresql.org/docs/current/ddl-rowsecurity.html)）。これは**バグではなくRLSが正しく仕事をしている**サインなので、「黙らせる」のではなく「なぜ満たさないか」を見ます。

### よくある3つの原因

```sql
-- 想定ポリシー
create policy "insert own" on public.matches for insert
to authenticated
with check ( (select auth.uid()) = user_id );
```

1. **`user_id` を入れ忘れている（最頻）**
   ```ts
   // ❌ user_id が null → (select auth.uid()) = null は偽 → 弾かれる
   await supabase.from("matches").insert({ opponent: "A", score: "3-1" });
   // ✅ user_id を明示する
   await supabase.from("matches").insert({ opponent: "A", score: "3-1", user_id: user.id });
   ```
   DBのデフォルト値で `user_id default auth.uid()` を設定しておくと、入れ忘れを構造的に防げます。

2. **他人/他テナントのIDを差し込んでいる**：`user_id` に別ユーザーのIDを入れている。これは**ポリシーが正しく防いでいる**ケース。アプリのバグを直す。

3. **UPDATEで「更新後の行」が境界を越える**：UPDATEは `USING`（更新できる既存行）と `WITH CHECK`（更新後の行）の両方が効きます。`tenant_id` を別テナントに書き換えるUPDATEは `WITH CHECK` で弾かれる。これも正しい挙動。

### 絶対にやってはいけない「解決」

```sql
-- ❌❌ これは解決ではなく、書き込みバイパスを開く穴
create policy "insert own" on public.matches for insert
to authenticated with check ( true );
```

`with check (true)` でエラーは消えますが、**誰でも任意の `user_id`・任意の `tenant_id` の行を作れる**ようになります。これは典型的な[書き込みバイパス脆弱性](/blog/supabase-rls-with-check-using-write-bypass-guide)で、AI生成コードや短納期で量産される最悪パターンです。エラーは「黙らせる」のではなく「述語を満たすデータを送る」で直してください。

---

## 3. 症状C：RLSが効かず、データが漏れる（最も危険）

症状AとBは「厳しすぎる」問題ですが、Cは逆——**緩すぎて見えてはいけない行が見える**。これは事故として最悪です。原因は次の通り。診断1（`relrowsecurity`）から疑います。

| 原因 | 診断 | 直し方 |
| --- | --- | --- |
| **enable忘れ** | 診断1で `rls_enabled = false` | `alter table ... enable row level security` |
| **service_roleで接続** | 診断3で `current_user = service_role` | クライアントは `anon`/publishableキーへ |
| **所有者バイパス** | 診断1で `rls_forced = false` かつ所有者接続 | `alter table ... force row level security` |
| **`using (true)` の置き忘れ** | 診断2のUSINGが `true` | 正しい述語に直す |
| **ビューがRLSをバイパス** | ビュー経由で全行見える | `security_invoker = true`（PG15+） |
| **SECURITY DEFINER関数の穴** | 関数経由で全行返る | `search_path=''`・最小権限で固める |

### enable忘れ：全テーブルを一括監査する

1テーブルずつ目視するのは漏れます。**公開スキーマでRLS未有効のテーブルを一覧**にして、CIのブロッキング条件にします。

```sql
-- public スキーマで RLS が無効なテーブル＝潜在的に全公開
select tablename
from pg_tables
where schemaname = 'public'
  and tablename not in (
    select c.relname from pg_class c
    join pg_namespace n on n.oid = c.relnamespace
    where n.nspname = 'public' and c.relrowsecurity = true
  );
```

これが**1行でも返ったら、そのテーブルはGRANTの範囲で全公開の可能性**があります。「`enable` 忘れの1テーブル」はSaaSで最も多い重大漏洩経路です。

### ビューの落とし穴

ビューはデフォルトで**作成者の権限で実行され、RLSをバイパス**します。`public_matches` のようなビューを `anon` に見せていると、裏のRLSを素通りして全行漏れます。PostgreSQL 15+ では `security_invoker` で**呼び出し元の権限＝RLSを尊重**させます。

```sql
create view public.match_summary
with (security_invoker = true)   -- ビューでもRLSを効かせる
as select id, opponent, score from public.matches;
```

### SECURITY DEFINER関数の穴

複雑な認可を `security definer` 関数に逃がすと、その関数は**RLSをバイパスして実行**されます。便利な反面、`search_path` を固定せず・部外者にも実行を許すと、**全行を返す穴**になります。`set search_path = ''`・完全修飾・最小権限で固めること（[詳細](/blog/supabase-security-definer-function-search-path-guide)）。

---

## 4. 切り分けフロー（保存版）

3症状をまたいだ一枚の判断フローです。詰まったらここに戻ってください。

```
データが「見えない」？
├─ 診断2: ポリシーは在る？ → 無 → SELECTポリシーを書く
├─ 診断3: auth.uid()はnull？ → はい → 認証経路（JWT伝播）を直す
├─ 診断4: 認証済み偽装で返る？ → はい → 問題はアプリ側。DBは正常
│                              → いいえ → USING述語の列/型/値を実データと照合
└─ permission deniedエラー → GRANT不足

INSERT/UPDATEが「弾かれる」？(42501)
└─ WITH CHECK違反 → 送る行の user_id/tenant_id を点検（true で黙らせない）

データが「漏れる」？
├─ 診断1: rls_enabled=false → enableする（全テーブル監査）
├─ 診断3: service_role接続 → anonキーへ
├─ 診断1: rls_forced=false＋所有者接続 → forceする
├─ ビュー経由 → security_invoker=true
└─ definer関数経由 → search_path=''・最小権限
```

---

## 5. 直したら必ず：許可と拒否の両方をテストで固定する

トラブルシューティングは**述語を書き換える作業**です。だから最後に必ず、**「許可されるべきが許可される」と「拒否されるべきが拒否される」の両方**を自動テストで固定します。特に症状B・Cを直した後は、述語を緩めて「動いたが漏れる」退行を作っていないかを、**拒否テスト**で確かめてください。

```sql
-- pgTAP例：部外者には1行も見えないことを「拒否」として固定する
begin;
  select plan(1);
  set local role authenticated;
  set local request.jwt.claims = '{"sub":"99999999-9999-9999-9999-999999999999"}';
  select is(
    (select count(*) from public.matches)::int, 0,
    '部外者には他人のmatchesが1行も見えない'
  );
  select * from finish();
rollback;
```

拒否テストの書き方とCIゲート化は[pgTAPでRLSを守る記事](/blog/supabase-rls-testing-pgtap-policy-regression-guide)で詳述しています。**「直った」をデモではなくテストで証明する**——これがトラブルシューティングを「もぐら叩き」から「恒久対策」に変える最後の一手です。

---

## まとめ：推測で直さず、証拠で直す

- RLSのデバッグは **`relrowsecurity` / `pg_policies` / `auth.uid()` / `set local role`** の4診断で「どこで止まっているか」を証拠で特定してから直す。
- **空で返る**＝ポリシー不在・未認証・GRANT不足・述語不一致。`service_role` で確認して満足しない。
- **`new row violates ...`（42501）**＝WITH CHECK違反。`with check(true)` で黙らせず、送るデータを直す。
- **漏れる**＝enable忘れ・service_role接続・所有者バイパス・ビュー/definer関数のバイパス。`relrowsecurity=false` をまず疑う。
- 直したら**許可と拒否の両方をpgTAPで固定**し、「動いたが漏れる」退行を出さない。

### 一次情報（必ず最新を確認してください）

- [Supabase: Row Level Security](https://supabase.com/docs/guides/database/postgres/row-level-security)
- [PostgreSQL: Row Security Policies](https://www.postgresql.org/docs/current/ddl-rowsecurity.html)
- [PostgreSQL: CREATE POLICY](https://www.postgresql.org/docs/current/sql-createpolicy.html)
