Row Level Security (RLS)
Supabase のセキュリティの心臓部。Postgres 標準機能で、 SQL クエリに自動で WHERE が注入される。クライアント直アクセスでも安全になる。
なぜ RLS が要るか
Supabase はクライアントから直接 Postgres に SQL を投げる構造。 anon key は公開されているため、テーブルに対してデフォルトで「全員が全件読み書きできる」状態。 RLS でSQL レベルで権限を定義することで、ブラウザから安全に DB を触らせる。
有効化
テーブルごとに ON にする。新しいテーブルは必ず最初に有効化。
alter table public.posts enable row level security;
有効化しただけでは誰も読み書きできなくなる。ポリシーを 1 つ以上書いて初めて操作可能になる。
ポリシーの基本構文
create policy <name>
on <table>
for <operation> -- select / insert / update / delete / all
to <role> -- public / anon / authenticated
using (<条件>) -- 行が見える条件(select / update / delete)
with check (<条件>); -- 書き込み時の検証(insert / update)
- USING: その行を「読める / 削除できる / update のために選べる」かどうか
- WITH CHECK: insert / update 後の状態が許されるか
使える関数
- auth.uid() — ログインユーザの UUID(未ログインなら NULL)
- auth.role() —
"anon"/"authenticated" - auth.email() — JWT の email クレーム
- auth.jwt() — JWT 全体(
->で要素アクセス) - current_setting('request.jwt.claims', true) — JWT の生 JSON
典型ポリシー集
1. 全員 read OK
create policy "Public read"
on posts for select
using (true);
2. 認証済みなら read OK
create policy "Authenticated read"
on posts for select
to authenticated
using (true);
3. 自分の行だけ read
create policy "Read own posts"
on posts for select
to authenticated
using (author_id = auth.uid());
4. 自分の行だけ insert
create policy "Insert own posts"
on posts for insert
to authenticated
with check (author_id = auth.uid());
WITH CHECK なので、INSERT する値の author_id が自分でなければ拒否される。
5. 自分の行だけ update / delete
create policy "Update own posts"
on posts for update
to authenticated
using (author_id = auth.uid())
with check (author_id = auth.uid());
create policy "Delete own posts"
on posts for delete
to authenticated
using (author_id = auth.uid());
6. 全部の操作を 1 つのポリシーで
create policy "Full access to own posts"
on posts for all
to authenticated
using (author_id = auth.uid())
with check (author_id = auth.uid());
7. 公開フラグ + オーナー
create policy "Read published or own"
on posts for select
using (status = 'published' or author_id = auth.uid());
8. ロールベース(admin チェック)
-- app_metadata.role に "admin" が入っているユーザだけ
create policy "Admin can do anything"
on posts for all
using (
coalesce(auth.jwt() -> 'app_metadata' ->> 'role', '') = 'admin'
);
9. 多対多(チームメンバ)
-- team_members テーブルに自分がいる team の posts だけ見える
create policy "Team posts"
on posts for select
using (
exists (
select 1 from team_members tm
where tm.team_id = posts.team_id
and tm.user_id = auth.uid()
)
);
10. 関数化(複雑な権限を共通化)
create or replace function is_team_member(team_id uuid)
returns boolean
language sql security definer set search_path = public
as $$
select exists(
select 1 from team_members
where user_id = auth.uid()
and team_id = $1
)
$$;
create policy "Team posts"
on posts for select
using (is_team_member(team_id));
SECURITY DEFINER + set search_path をセットで書くのが安全パターン。
USING と WITH CHECK の違いがハマるところ
| 操作 | USING | WITH CHECK |
|---|---|---|
| SELECT | ✅ 必須 | — |
| INSERT | — | ✅ 必須 |
| UPDATE | ✅ 既存行が見える条件 | ✅ 更新後の値の条件 |
| DELETE | ✅ 必須 | — |
UPDATE でWITH CHECK を書き忘れると、自分の行を他人の行に書き換えられる事故が起きる。
無料の落とし穴
- テーブルを作って RLS を有効にしないまま本番投入してしまう
- 「とりあえず
using (true)」のまま - UPDATE に WITH CHECK を書き忘れる
- service_role キーをフロントで使ってしまう
- storage.objects にもポリシーが要ることを忘れる
ポリシーの確認
-- 一覧
select schemaname, tablename, policyname, permissive, roles, cmd, qual, with_check
from pg_policies
where schemaname = 'public';
-- 削除
drop policy "policy name" on posts;
テスト
Studio 内の SQL Editor で、ロールを切り替えてテスト:
-- 認証済みのふり
set local role authenticated;
set local "request.jwt.claims" to '{"sub": "uuid-of-alice", "role": "authenticated"}';
select * from posts; -- alice として実行されたかのよう
reset role;
pgTap でユニットテスト
begin;
select plan(3);
-- alice として
set local "request.jwt.claims" to '{"sub":"alice-uuid","role":"authenticated"}';
select results_eq(
'select count(*) from posts',
'select 5::bigint',
'alice should see 5 posts'
);
-- bob として
set local "request.jwt.claims" to '{"sub":"bob-uuid","role":"authenticated"}';
select results_eq(
'select count(*) from posts',
'select 2::bigint',
'bob should see 2 posts'
);
select * from finish();
rollback;
Performance
ポリシーが複雑だと SELECT のたびに評価される → 遅い。
パフォーマンスを保つコツ
- ポリシー内でEXISTS が必要な場合はインデックスを必ず
- 共通権限はSQL 関数に切り出す(プランナがキャッシュしやすい)
- ポリシー条件で使われるカラムにインデックス
- Postgres 15+ の
(select auth.uid())ラッピングで再評価回避(auth.uid() の最適化トリック)
-- 遅い書き方
create policy ... using (author_id = auth.uid());
-- Postgres プランナにヒントを与える
create policy ... using (author_id = (select auth.uid()));
サーバ側で RLS を信用する
Edge Function や Next.js API Route で anon key + Authorization ヘッダを使えば、 その関数もユーザ権限で動作する。SQL 1 行で書けて、認可ロジックを書く必要が無い。
// サーバ側だが「ユーザの権限で」DB アクセス
const supabase = createServerClient(URL, ANON_KEY, { ...cookieAdapter })
const { data } = await supabase.from("posts").select()
// alice なら alice が見られるものだけが返る
サーバ側で RLS を回避するとき
管理タスクや横断的な処理は service_role キーで RLS をバイパス:
const admin = createClient(URL, SERVICE_ROLE_KEY)
await admin.from("posts").update(...).eq(...)
// ↑ どんな行でも書き換えられる。サーバ側でしか使わない
Storage RLS
Storage も内部的には storage.objects テーブル。
同じ仕組みでポリシーを書く(→ Storage)。
Realtime RLS
購読時にも RLS が評価され、読めない行は配信されない。詳細は Realtime。
よくあるパターン集
「Friendship」: お互いを承認した友人だけ見られる
create policy "Read friends posts"
on posts for select
using (
author_id = auth.uid()
or exists (
select 1 from friendships f
where (
(f.user1 = auth.uid() and f.user2 = posts.author_id)
or (f.user2 = auth.uid() and f.user1 = posts.author_id)
) and f.status = 'accepted'
)
);
「ソフト削除」
create policy "Hide deleted"
on posts for select
using (deleted_at is null or author_id = auth.uid());
「下書きは本人のみ」
create policy "Read drafts of self only"
on posts for select
using (
status = 'published'
or (status = 'draft' and author_id = auth.uid())
);
デバッグ
- 「permission denied for table」は RLS 拒否
- Studio の Auth → Users で別アカウントを作って実機テスト
- SQL Editor で
set local role authenticated+set local "request.jwt.claims" explain (verbose) select ...でフィルタが SQL に注入されているか確認
マイグレーションでまとめて書く
-- supabase/migrations/20260101000000_init_posts.sql
create table posts (
id uuid primary key default gen_random_uuid(),
author_id uuid references auth.users(id) on delete cascade,
title text not null,
content text,
status text default 'draft',
created_at timestamptz default now()
);
alter table posts enable row level security;
create policy "Public read published"
on posts for select
using (status = 'published');
create policy "Read own"
on posts for select to authenticated
using (author_id = (select auth.uid()));
create policy "Insert own"
on posts for insert to authenticated
with check (author_id = (select auth.uid()));
create policy "Update own"
on posts for update to authenticated
using (author_id = (select auth.uid()))
with check (author_id = (select auth.uid()));
create policy "Delete own"
on posts for delete to authenticated
using (author_id = (select auth.uid()));
create index posts_author_idx on posts (author_id);
create index posts_status_idx on posts (status);
公開前に「RLS が有効になっていない table」を SQL で洗い出す:
select tablename from pg_tables where schemaname='public' and rowsecurity = false;
これでヒットしたら即座に enable。
セキュリティ Tips の総まとめ
- テーブル作成後は必ず RLS 有効化
- UPDATE ポリシーにはUSING + WITH CHECK 両方
- service_role キーはブラウザに置かない
- app_metadata.role でロール管理(user_metadata は信用しない)
- 定期的に「
permissive = trueなポリシー」を棚卸し - テストを CI で回す(pgTap)
「RLS はWHERE 句の自動注入」。クエリを select * from posts where ... に書き換えてくれる仕組み。
そう捉えるとパフォーマンスもデバッグも理解しやすい。