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)

使える関数

典型ポリシー集

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 の違いがハマるところ

操作USINGWITH CHECK
SELECT✅ 必須
INSERT✅ 必須
UPDATE✅ 既存行が見える条件✅ 更新後の値の条件
DELETE✅ 必須

UPDATE でWITH CHECK を書き忘れると、自分の行を他人の行に書き換えられる事故が起きる。

無料の落とし穴

ポリシーの確認

-- 一覧
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 のたびに評価される → 遅い。

パフォーマンスを保つコツ

-- 遅い書き方
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())
  );

デバッグ

マイグレーションでまとめて書く

-- 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 が腹落ちする一言

「RLS はWHERE 句の自動注入」。クエリを select * from posts where ... に書き換えてくれる仕組み。 そう捉えるとパフォーマンスもデバッグも理解しやすい。