RLS(行レベルセキュリティ)のデバッグで最もやってはいけないのは、「とりあえずポリシーを using (true) にして動くか試す」ことです。それは「鍵が開かないから玄関を外した」のと同じで、動いたように見えて全データを公開しています。トラブルシューティングの原則は一つ——推測でポリシーをいじらず、まず『どこで止まっているか』を証拠で特定する。
この記事は、Supabase + PostgreSQL の RLS で遭遇する3大症状を、原因別に切り分けて直す実践ガイドです。
- SELECT が空で返る(読めるはずが0件)
new row violates row-level security policyで INSERT が弾かれる- RLS が効かず、データが漏れる(見えてはいけない行が見える)
題材はリアルタイム試合記録アプリ(69テーブル全RLS・280ポリシー)の運用知見。内容はSupabase公式・PostgreSQL公式(2026年6月時点)に忠実です。
RLSの前提(有効化・USING/WITH CHECK・ロール)はRLS入門、Next.js特有の「クライアント生成ミスで空が返る」はApp Router統合ガイドで扱っています。本記事はDB側の切り分けに集中します。
0. まず手に入れる:4つの診断クエリ
症状を見る前に、証拠を集める道具を用意します。この4つで「RLSの状態」が丸裸になります。
診断1:RLSは有効か?所有者バイパスは?
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:どんなポリシーが付いているか?
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()は何を返すか?
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バイパス)で動くため、バグを再現できません。特定ユーザーに偽装します。
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は統合ガイド) |
| GRANT不足 | permission denied for table エラー | grant select ... to authenticated |
| USINGの述語不一致 | 診断4でも0件 | 述語の列・型・値を実データと突き合わせる |
切り分けの順番
- 診断2でポリシーを見る。 0件なら答えは出た——
enableしたのにSELECTポリシーが無く、デフォルト拒否で全部消えている。 - 診断3で
uidを見る。nullなら未認証。アプリ側はトークンを送れているか(Next.jsならクライアント生成・middleware)。 - 診断4で認証済みになりきる。 ここで正しく行が返るなら、問題はDBではなくアプリの認証経路。逆に診断4でも空なら、ポリシーの述語が間違っている。
- 述語不一致の典型:
user_idに入っているのがauth.users.idではなく別IDだった、型がtextvsuuidでキャストが必要だった、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公式)。これはバグではなくRLSが正しく仕事をしているサインなので、「黙らせる」のではなく「なぜ満たさないか」を見ます。
よくある3つの原因
-- 想定ポリシー
create policy "insert own" on public.matches for insert
to authenticated
with check ( (select auth.uid()) = user_id );
-
user_idを入れ忘れている(最頻)// ❌ 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()を設定しておくと、入れ忘れを構造的に防げます。 -
他人/他テナントのIDを差し込んでいる:
user_idに別ユーザーのIDを入れている。これはポリシーが正しく防いでいるケース。アプリのバグを直す。 -
UPDATEで「更新後の行」が境界を越える:UPDATEは
USING(更新できる既存行)とWITH CHECK(更新後の行)の両方が効きます。tenant_idを別テナントに書き換えるUPDATEはWITH CHECKで弾かれる。これも正しい挙動。
絶対にやってはいけない「解決」
-- ❌❌ これは解決ではなく、書き込みバイパスを開く穴
create policy "insert own" on public.matches for insert
to authenticated with check ( true );
with check (true) でエラーは消えますが、誰でも任意の user_id・任意の tenant_id の行を作れるようになります。これは典型的な書き込みバイパス脆弱性で、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のブロッキング条件にします。
-- 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を尊重させます。
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 = ''・完全修飾・最小権限で固めること(詳細)。
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を直した後は、述語を緩めて「動いたが漏れる」退行を作っていないかを、拒否テストで確かめてください。
-- 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を守る記事で詳述しています。「直った」をデモではなくテストで証明する——これがトラブルシューティングを「もぐら叩き」から「恒久対策」に変える最後の一手です。
まとめ:推測で直さず、証拠で直す
- 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で固定し、「動いたが漏れる」退行を出さない。