「Supabaseでテーブルを作ったら、anon キーで誰でも全行読めてしまった」——RLSを知らずにSupabaseを使い始めると、ほぼ全員がこの事故を一度は踏みます。逆に、RLSを有効化した瞬間に「今度は自分でも1行も読めなくなった」と戸惑う。この2つの戸惑いの正体を解きほぐすのが、この入門記事のゴールです。
読み終えると、あなたは最初のRLSポリシーを自信を持って書け、なぜそれで安全になるのかを説明でき、初心者が必ずハマる5つの罠を最初から回避できるようになります。題材には、私が一人で構築した複数人同時編集のリアルタイム試合記録アプリ(69テーブル全てにRLSを有効化し、約280本のポリシーを本番運用)の設計判断を交えます。内容はSupabase公式・PostgreSQL公式(2026年6月時点)に忠実です。
この記事の位置づけ:これは「RLSの一番最初」を扱う入門です。書けるようになった後の道筋——マルチテナント本番設計、パフォーマンス最適化、pgTAPテスト——は各記事へ繋ぎます。まずはここで土台を固めましょう。
1. RLSとは何か:全クエリに自動で付く「暗黙のWHERE」
行レベルセキュリティ(Row-Level Security, RLS)を一言で言うと、テーブルへのあらゆるクエリに、データベースが自動で付け足す WHERE 句です。
たとえば profiles テーブルに「自分の行しか見えない」というポリシーを書くと、アプリが select * from profiles という無防備なSQLを投げても、PostgreSQLは内部でこう実行します。
-- アプリが投げたSQL
select * from profiles;
-- PostgreSQLが実際に実行するSQL(ポリシーが暗黙のWHEREとして付く)
select * from profiles where (auth.uid() = user_id);
ここが決定的に重要です。この where はアプリ側のコードではなく、DB側に存在します。 だから——
- どんなクライアント(ブラウザ・モバイル・curl・他人のスクリプト)から来ても効く
- アプリのコードに
where user_id = ?を書き忘れても効く - 新しいエンドポイントを足しても、自動で効く
アプリの if (user.id === row.ownerId) のような出し分けは「書き忘れたら漏れる」のに対し、RLSは最終防衛線としてDBが弾く。これがRLSの価値であり、認可を「アプリの善意」ではなく「DBの構造」で守るということです。
2. 出発点:enableした瞬間に「デフォルト拒否」になる
最初のSQLはこれです。テーブルにRLSを有効化します。
alter table public.profiles enable row level security;
ここで初心者が必ず驚くことが起きます。有効化した直後、ポリシーを1本も書いていなければ、(テーブル所有者以外は)誰も1行も読めなくなります。 PostgreSQL公式の言葉では「ポリシーが存在しなければ、デフォルト拒否(default-deny)が適用され、行は見えず変更もできない」(PostgreSQL: Row Security Policies)。
これは機能であって、バグではありません。RLSの設計思想は「まず全部閉じる。許可は明示的に1本ずつ足す」というホワイトリスト(許可リスト)方式です。だから手順は必ずこの順番になります。
enable row level securityで全部閉じるcreate policy ...で必要な許可だけを開ける
逆をやってはいけません。「とりあえず全部開けておいて後で閉じる」は、閉じ忘れた瞬間に全公開です。閉じてから開ける——これがRLSの鉄則です。
3. つまずきの最大関門:GRANT と POLICY は別物(2層モデル)
入門者が最も混乱するのがここなので、最初に潰します。Supabaseでテーブルにアクセスできるには、2つの別々の関門を両方通る必要があります。
| 層 | 何を決めるか | 構文 | たとえると |
|---|---|---|---|
| GRANT(権限) | そのテーブルを そもそも操作してよいか(テーブル単位) | grant select on ... to authenticated | 建物に入る鍵 |
| POLICY(RLS) | どの行を 操作してよいか(行単位) | create policy ... using (...) | 部屋ごとの入室許可 |
両方が true のときだけアクセスできます。 GRANTがなければRLS以前に弾かれ、GRANTがあってもポリシーが無ければデフォルト拒否で弾かれる。「ポリシーを書いたのにアクセスできない」「RLSを無効にしているのにアクセスできない」の多くは、この2層の取り違えです。
Supabaseでは、テーブルをダッシュボードで作ると anon / authenticated ロールへのGRANTが自動で付くことが多いため、GRANT層を意識しないまま動いてしまい、かえって混乱の種になります。公式が示す明示的なGRANTはこうです。
grant select on public.profiles to anon;
grant select, insert, update, delete on public.profiles to authenticated;
grant select, insert, update, delete on public.profiles to service_role;
覚え方:GRANTは「テーブルに触れるか(粗い)」、POLICYは「どの行に触れるか(細かい)」。RLSはGRANTをさらに絞るもので、置き換えるものではありません。
4. 誰として実行されるか:anon / authenticated / service_role
ポリシーを書く前に、Supabaseが「リクエストを誰として実行するか」を理解します。Supabaseは受け取ったリクエストを、JWT(トークン)の有無に応じて3つのPostgresロールにマップします(Supabase公式)。
| ロール | いつ使われるか | RLS | 使う場所 |
|---|---|---|---|
anon | トークン無し(未ログイン) | 効く | 公開データの読み取り |
authenticated | 有効なユーザートークンあり(ログイン済み) | 効く | 通常のアプリ操作 |
service_role | サービスロールキー使用 | バイパスする | サーバー側の管理処理のみ |
ここで入門者にとって最重要の警告です。
service_roleキーは RLS を完全にバイパスします。絶対にブラウザ・モバイルアプリ・公開リポジトリに置かないでください。 これはサーバー(信頼できる環境)専用です。クライアントには必ずanon(publishable)キーを使います。
service_role キーがクライアントに漏れる=RLSを全部書いた意味が消える、という事故は後を絶ちません。鍵の取り違えだけは、入門段階から型として避けてください(詳細:anon/service_roleキー露出)。
ポリシー内で「今のユーザーは誰か」を知るには、auth.uid()(ユーザーID)と auth.jwt()(トークン全体)を使います。未認証時、auth.uid() は null を返します。
5. 最初のポリシーを書く:USING と WITH CHECK
ポリシーには条件式を書く場所が2つあります。役割がまったく違うので、ここを最初に正確に掴みます。
USING (...)— 既存の行に対する検査。trueになった行だけが「見える/触れる」。=読み取り側のフィルタWITH CHECK (...)—INSERT/UPDATEで生まれる新しい行に対する検査。falseなら弾かれる。=書き込み側のフィルタ
どのコマンドでどちらが効くかが、ポリシー設計の地図です(PostgreSQL: CREATE POLICY)。
| コマンド | USING(既存行) | WITH CHECK(新しい行) |
|---|---|---|
SELECT | ✅ 効く | — |
INSERT | — | ✅ 効く |
UPDATE | ✅ 効く | ✅ 効く |
DELETE | ✅ 効く | — |
「自分の profiles 行だけを読み書きできる」を、コマンドごとに4本書きます。(select auth.uid()) と副問い合わせで包むのは、最初からパフォーマンスの効く形で書く癖です(理由:性能最適化)。
-- 読む:自分の行だけ見える
create policy "Users can view own profile"
on public.profiles for select
to authenticated
using ( (select auth.uid()) = user_id );
-- 作る:自分のuser_idを持つ行しか作れない(他人になりすませない)
create policy "Users can insert own profile"
on public.profiles for insert
to authenticated
with check ( (select auth.uid()) = user_id );
-- 更新:自分の行だけ対象にでき(USING)、更新後も自分の行のまま(WITH CHECK)
create policy "Users can update own profile"
on public.profiles for update
to authenticated
using ( (select auth.uid()) = user_id )
with check ( (select auth.uid()) = user_id );
-- 消す:自分の行だけ消せる
create policy "Users can delete own profile"
on public.profiles for delete
to authenticated
using ( (select auth.uid()) = user_id );
なぜ INSERT に WITH CHECK が要るのか
INSERTには「既存行」が無いので USING は使えません。代わりに WITH CHECK で「作ろうとしている新しい行」を検査します。with check ((select auth.uid()) = user_id) は「user_id 列に自分のIDが入っている行しか作れない」という意味。これを書かないと、認証済みユーザーが他人の user_id を持つ行を捏造できます。「自分の行しか読めない」テーブルに「他人の行を書ける」穴が同居する——これがWITH CHECK欠落による書き込みバイパスです。
公開データを読ませたいとき
「ログインユーザーなら全員読める」なら using (true) を、未ログインにも見せるなら to anon を足します。
-- 認証済みなら誰でも読める公開プロフィール一覧
create policy "Public profiles are viewable by signed-in users"
on public.profiles for select
to authenticated
using ( true );
using (true) は「行による制限なし(GRANTの範囲で全部読める)」という意味で、意図した公開です。問題は意図せず true にしてしまうこと。だから「なぜこの行は公開してよいのか」を常に説明できる状態にしておきます。
6. 一段進んだ基礎:FORCE・auth.jwt()・restrictive
入門の締めに、すぐ必要になる3つの概念を軽く触れておきます。深掘りは各リンク先へ。
テーブル所有者もRLSに従わせる:FORCE
PostgreSQLではテーブル所有者は通常RLSをバイパスします(PostgreSQL公式)。所有者として接続するバックエンド処理でもRLSを効かせたいなら、明示します。
alter table public.profiles force row level security;
JWTのクレームで判定する:auth.jwt()
ロールやチーム所属など、DBを引かずにトークンの中身だけで判定したいときは auth.jwt() を使います。ただし認可に使ってよいのは app_metadata(書き換え不可)だけで、ユーザーが書き換えられる user_metadata を認可に使うのは厳禁です(応用:RBAC設計)。
「絶対に外せない条件」をANDで固定する:restrictive
通常のポリシー(permissive)はORで結合され「許可を足す」ものです。対して as restrictive はANDで結合され「全体に制約をかける」。テナント境界やMFA要求のような外せない条件はrestrictiveで固定します。
-- 二要素認証(aal2)を通したセッションでなければ更新を一切許さない(全UPDATEにANDで効く)
create policy "Require MFA for updates"
on public.profiles as restrictive for update
to authenticated
using ( (select auth.jwt()->>'aal') = 'aal2' );
7. 入門者が必ずハマる5つの罠(チェックリスト)
最後に、私が現場で何度も見てきた「最初の事故」を、回避法とセットで。デプロイ前にこの5つを指差し確認してください。
| # | 罠 | 症状 | 回避 |
|---|---|---|---|
| 1 | enable を忘れる | RLSを書いたつもりが全行公開 | 公開スキーマの全テーブルで enable。CIで未有効化を検査 |
| 2 | service_role をクライアントで使う | RLSが全部バイパスされ無意味化 | クライアントは anon/publishableキーのみ。service_roleはサーバー限定 |
| 3 | WITH CHECK を忘れる | 読めないが他人の行を書ける | INSERT/UPDATEに必ず WITH CHECK。SELECTのテストだけで安心しない |
| 4 | GRANT と POLICY を混同 | 「ポリシー書いたのに動かない/消したのに動く」 | 2層モデルを思い出す。両方がtrueで初めて通る |
| 5 | テストが「許可」だけ | ポリシーを緩めた退行に気づけない | 「拒否されるべきが拒否される」もpgTAPでテスト |
まとめ:RLSは「閉じてから、明示的に開ける」
- RLSは全クエリに付く暗黙のWHERE。アプリの善意ではなくDBの構造で認可を守る最終防衛線。
enable row level securityでデフォルト拒否になり、create policyで許可を1本ずつ開ける。- アクセスには GRANT(テーブル権限)と POLICY(行の許可)の2層が両方要る。
USING=見える既存行、WITH CHECK=書ける新しい行。INSERT/UPDATEのWITH CHECKを忘れない。service_roleはRLSをバイパスする——クライアントに置かない。
最初の一歩を踏めたら、次はマルチテナント設計・Next.jsとの統合・性能へ。RLSは「書いて終わり」ではなく、設計・統合・性能・テストの4点で本番に耐えます。