メインコンテンツへスキップ
友田 陽大
データベース・RLS
Supabase
RLS
PostgreSQL
セキュリティ
テスト

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)で体系的にデバッグします。公式準拠で、推測ではなく証拠で直す手順。

公開日
読了時間
10分
著者
友田 陽大
シェア

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

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

  1. SELECT が空で返る(読めるはずが0件)
  2. new row violates row-level security policy で INSERT が弾かれる
  3. 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

uidnull なら未認証扱いで、(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で uidnull認証経路を直す(Next.jsは統合ガイド
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公式)。これはバグではなくRLSが正しく仕事をしているサインなので、「黙らせる」のではなく「なぜ満たさないか」を見ます。

よくある3つの原因

-- 想定ポリシー
create policy "insert own" on public.matches for insert
to authenticated
with check ( (select auth.uid()) = user_id );
  1. 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() を設定しておくと、入れ忘れを構造的に防げます。

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

  3. 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 = falsealter 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で固定し、「動いたが漏れる」退行を出さない。

一次情報(必ず最新を確認してください)

友田

友田 陽大

経済産業大臣賞 受賞プロダクト開発者。TypeScript + Python + AWS で、SaaS・業界DX・ 実用レベルの生成AI(RAG)を、要件定義からインフラ・運用まで一人で完遂します。

お困りごとはありませんか?

設計から実装・運用まで、一人 × 生成AI で伴走します

この記事のような実装を、要件定義から本番運用まで一気通貫で。まずは30分の無料技術相談から、状況をお聞かせください。

プロジェクト単位(請負)・技術顧問のどちらにも対応可能です。まずは30分の無料技術相談から。

あわせて読みたい