TL;DR
以下は、ほとんどのSupabaseアプリに必要なRLSポリシーを、ゲスト、行の所有者、サインイン済みの別のユーザーとして、REST API経由で実行した結果です。コピーする前に知っておきたい結果が3つあります。他人の行へのupdateは、エラーではなく空のリストを添えた200を返すこと。一見無害なメンバーシップテーブルのinsertポリシーのせいで、ユーザーが別の会社に参加し、その会社のプロジェクトを読めたこと。そして、auth.uid()の代わりに(select auth.uid())と書くだけで、200,000行のクエリが156 msから7.4 msになったことです。
私はDenys、フルスタックかつAIエンジニアで、私が作る、または直すアプリの多くはSupabaseで動いています。ポリシーのスニペットはどこにでもありますが、実際のリクエストが当たったときに何が起きるかは、ほとんど示されません。そこで実行してみました。ここにあるのは、私がいつも最初に使うSupabase RLSポリシーと、それぞれの結果です。
テスト方法。 ほとんどのリクエストは、ローカルのSupabaseスタック(CLI 2.109.1、Postgres 17.6、PostgREST 14.14、Auth 2.192.0)に、公開キーと、サインイン済みの実在のユーザー2人(AとB)で送りました。構文エラーや計測のようにHTTPが要らないケースは、Supabaseのanonロールとauthenticatedロールを置き、auth.uid()を作り直した素のローカルPostgres 17.10で実行しました。すべて2026年10月24日、架空の行で行っています。
どのポリシーより先に:テーブルにgrantはある?
ポリシーは、ロールがどの行を見られるかを決めます。grantは、ロールがそもそもテーブルに届くかを決めます。現在のSupabaseプロジェクトでは、新しいテーブルにData API用のgrantが自動では付かなくなっています(2026年10月30日からはすべてのプロジェクトで)。私のスタックで、RLSがオンでgrantのないテーブルは次のように応答しました。
| リクエスト | ステータス | ボディ |
|---|---|---|
ゲスト GET /invoices |
401 |
permission denied for table invoices、GRANT SELECT ... TO anonのヒント付き |
ユーザーA GET /invoices |
403 |
同じ内容で、authenticated向けのヒント付き |
そのため、アプリがブラウザから読むテーブルすべてで、最初にやることはこれです。
alter table public.todos enable row level security;
grant select, insert, update, delete on public.todos to authenticated;
ブラウザが必要とするコマンドだけを、ゲストに読ませる必要がない限りauthenticatedにだけ付与してください。
ユーザーに自分の行だけを見せるには?
コマンドごとに1つのポリシーを、それぞれサインイン済みのユーザーに限定して作ります。
create policy "read own" on public.todos
for select to authenticated using ((select auth.uid()) = user_id);
create policy "add own" on public.todos
for insert to authenticated with check ((select auth.uid()) = user_id);
create policy "edit own" on public.todos
for update to authenticated
using ((select auth.uid()) = user_id) with check ((select auth.uid()) = user_id);
create policy "delete own" on public.todos
for delete to authenticated using ((select auth.uid()) = user_id);
Aは2行、Bは1行を持っています。返ってきた結果です。
| リクエスト元 | 内容 | ステータス | ボディ |
|---|---|---|---|
| ゲスト | 全件読み取り | 401 |
permission denied(anonにgrantなし) |
| A | 全件読み取り | 200 |
Aの2行 |
| A | Bの行をinsert | 403 |
new row violates row-level security policy |
| A | Bの行をupdate | 200 |
[] |
| A | Bの行をdelete | 200 |
[] |
| A | 自分の行の所有者をBに変更 | 403 |
new row violates row-level security policy |
覚えておくべきは、2つの200 []の行です。RLSは、見えない行へのupdateやdeleteを拒否しません。除外するだけで、変更された行がゼロでも成功として扱われます。アプリが「エラーなし」を「保存できた」とみなしていると、実際には何も起きていないのに、ユーザーに編集できたと伝えてしまいます。変更された行を返してもらい(supabase-jsでは.select()、HTTPではPrefer: return=representation)、1行返ってきたことを確認してください。
updateポリシーになぜWITH CHECKが必要?
usingは、ユーザーが触れる既存の行を選びます。with checkは、その行が変更後にどんな状態であってよいかを決めます。同じupdateポリシーの3つのバージョンを比べました。
| updateポリシー | Aが自分の行をBに渡す |
|---|---|
using (uid = user_id) with check (uid = user_id) |
403、拒否 |
using (uid = user_id)、with checkなし |
拒否(Postgresはusingをcheckとして再利用) |
using (uid = user_id) with check (true) |
200、行はBのものに |
with checkを省くのは見た目より安全です。Postgresがusingの式にフォールバックするからです。ユーザーが行を他人のアカウントへ移せてしまうのは、一部のジェネレーターが「updateを許可する」ために書くwith check (true)のほうです。checkは省略せずに書いてください。
プロフィールを公開しつつ、所有者だけが編集できるようにするには?
create policy "public read" on public.profiles
for select to anon, authenticated using (true);
create policy "own edit" on public.profiles
for update to authenticated
using ((select auth.uid()) = id) with check ((select auth.uid()) = id);
素のPostgresでの実行では、ゲストは2つのプロフィールを読め、ゲストのupdateは0行を変更し、Aがフィルターなしで実行したupdate profiles set display_name = 'x'は、Aの行だけのちょうど1行を変更しました。公開するつもりのデータへのselectなら、using (true)で問題ありません。ほかのテーブルでは、RLSなしと同じです。また、to句なしで書いたポリシーはゲストにも適用されます。私のゲストは、toのないusing (true)ポリシーを通じて「非公開」の行を読めました。
チーム内で行を共有するには?
よくある形は、membershipsテーブルと、そこからユーザーのチームを引くデータ側のポリシーです。
create policy "members read" on public.projects
for select to authenticated
using (org_id in (select org_id from public.memberships
where user_id = (select auth.uid())));
このポリシー自体は問題ありません。漏洩はその隣にありました。membershipsには、多くのチュートリアルが勧める「ユーザーは自分のメンバーシップをinsertできる」というポリシーを付けました。
create policy "join any org" on public.memberships
for insert to authenticated with check (user_id = (select auth.uid()));
これは行が誰のものかを確認するだけで、どのチームかは確認しません。会社1のメンバーであるAが、リクエストを1つ送りました。
| Aのリクエスト | ステータス | ボディ |
|---|---|---|
| プロジェクトを読む | 200 |
A roadmap |
メンバーシップ{user_id: A, org_id: 2}をinsert |
201 |
作成された |
| もう一度プロジェクトを読む | 200 |
A roadmap、B payroll |
対策は、membershipsにinsertポリシーを一切付けず、ブラウザから書き込めなくすることです(素のPostgresの実行では、同じinsertは42501で拒否されました)。メンバーの追加は、サーバー側、または招待を確認する関数の中で動く招待フローで行います。ユーザーが2つの会社に所属する場合が次の落とし穴で、それは2つの組織に属すユーザーで起きる漏洩にまとめました。
ポリシーが自分自身のテーブルを読む必要があるときは?
「メンバーは同じチームの仲間を見られる」は、membershipsを読むmemberships上のポリシーを意味します。Postgresはクエリの実行時に拒否します。
ERROR 42P17: infinite recursion detected in policy for relation "members2"
検索を、所有者として動く関数に移します。APIに公開されていないスキーマに置き、search_pathを固定します。
create schema private;
create function private.user_org_ids() returns setof int
language sql stable security definer set search_path = ''
as $$ select org_id from public.members2 where user_id = (select auth.uid()) $$;
revoke all on function private.user_org_ids() from public;
grant usage on schema private to authenticated;
grant execute on function private.user_org_ids() to authenticated;
create policy "see teammates" on public.members2
for select to authenticated using (org_id in (select private.user_org_ids()));
その後、Aは会社1の2人のメンバーを見られ、Bは自分だけが見えました。関数はpublicに置かないでください。そこにあるものは何でもAPI経由で呼び出せます。
機密性の高い行にMFAを必須にするには?
通常のポリシーに加えて、restrictiveポリシーを追加します。restrictiveポリシーはandで結合されるので、両方を通過する必要があります。
create policy "mfa only" on public.payouts as restrictive
for select to authenticated using ((select auth.jwt() ->> 'aal') = 'aal2');
同じユーザーで、aal1のセッションは支払いを0件、aal2のセッションは1件読みました。
RLSをオンにしてポリシーを置かないテーブルは?
サーバーだけが触るテーブルです。RLSがオンでポリシーがなければ、ブラウザのロールにはすべて拒否されます。素のPostgresの実行では、ゲストもAも0行を読み、Aのinsertは42501で拒否されました。RLSをオフのままにするのは、その逆です。RLSがオフでgrantがデフォルトのテーブルを、公開キーのみ、サインインなしで読んだ結果です。
| ゲストのリクエスト | ステータス | ボディ |
|---|---|---|
GET /leads |
200 |
2行とも |
DELETE /leads?id=gt.0 |
200 |
2行とも、削除された |
Advisorの警告「RLS disabled in public」が指しているのは、これです。
1つのポリシーで複数のコマンドをカバーできる?
リストでは書けません。for update, deleteは構文エラー(42601: syntax error at or near ",")になりますが、公開されているスニペット集には今も載っています。for allは有効で、1つのusingと1つのwith checkをすべてのコマンドに適用しますが、それが望みどおりになることはまれです。using付きのinsertポリシーも失敗します。only WITH CHECK expression allowed for INSERT。
ポリシーでクエリは遅くなる?
遅くなることがあります。ポリシーの式は、Postgresが対象にする行ごとに実行されます。1,000ユーザーが持つ200,000行(Aは200行)で、ユーザーAとしてselect * from bigを計測しました(7回の中央値、Postgres 17.10)。
| ポリシーとインデックス | 中央値 |
|---|---|
auth.uid() = user_id、インデックスなし |
156.3 ms |
(select auth.uid()) = user_id、インデックスなし |
7.4 ms |
auth.uid() = user_id、user_idにインデックス |
0.21 ms |
(select auth.uid()) = user_id、インデックスあり |
0.05 ms |
呼び出しをselectで包むと、Postgresは行ごとではなくクエリごとに1回だけ計算できます。残りはインデックスが担います。SupabaseのRLSパフォーマンスガイドも、自社の100,000行のテーブルで同じ桁の変化(179 msから9 ms)を報告しています。多くのブログ記事がその数字を自分のものとして引用していますが、上の数字は私の実行によるものです。
ロールはどこから取るべき?
app_metadataか自分のテーブルからです。user_metadataからは決して取らないでください。サインイン済みのユーザーはsupabase.auth.updateUser()で自分のuser_metadataを変更できるため、auth.jwt() -> 'user_metadata' ->> 'role'を確認するポリシーでは、自分でそのロールを付与できてしまいます。サインアップ時のメタデータからロールをコピーするサインアップトリガーも同じです。そのケースは登録だけでOwnerににあります。
自分のポリシーをどう確認する?
- 無料のSupabaseセキュリティチェックは、プロジェクトを読み取り専用で読み、常に真になるポリシー、ゲスト向けの書き込みポリシー、ユーザーのメタデータを信用するポリシー、RLSがオフのテーブルを指摘し、それぞれに修正用のSQLを付けます。
- どの行が誰のものかを知っているスキャナーはありません。それには、この記事のように2人のユーザーとしてリクエストを実行します。ロールのあるアプリ向けの完全な方法はSupabase RLSのテスト方法にあります。
- Supabaseが守ってくれる範囲と、あなたに任される範囲はSupabaseは安全?にあります。
- 所有者とチームの同じテストをAppwriteでも実行した結果は、Appwrite vs Supabaseの安全性:同じ攻撃で比較にあります。
制限。 ローカルのスタック1つとノートPC1台、架空のデータ、2026年10月24日。タイミングはハードウェアとテーブルの形で変わります。ミリ秒より比率のほうが重要です。StorageのポリシーとRealtimeはここでは扱いません。