Supabase RLS の実践パターン集 — 日記アプリで使った7つのポリシー設計
はじめに
Supabase の Row Level Security(RLS)は強力ですが、実際のアプリケーションでどう設計するかは意外と情報が少ないです。
個人開発の日記アプリ Storyie では、27 テーブルすべてに RLS ポリシーを設定しています。本記事では、実際に運用しているポリシーを 7 つのパターン に分類し、それぞれの設計意図とハマりどころを共有します。
パターン一覧
| # | パターン | ユースケース |
|---|---|---|
| 1 | 自分の行だけ CRUD | ユーザー設定、デバイストークン |
| 2 | 公開 or 自分の行を SELECT | 投稿、メモ(visibility カラム) |
| 3 | 全員 SELECT + 自分だけ INSERT/DELETE | いいね、コメント |
| 4 | テナントメンバーシップで制御 | マルチテナント系テーブル |
| 5 | ロールベースの権限分離 | メンバー管理(owner/admin のみ変更可) |
| 6 | service_role 専用 | 閲覧ログ、集計用テーブル |
| 7 | INSERT のみ許可(Write-only) | アクティビティログ、分析イベント |
パターン 1: 自分の行だけ CRUD
最もシンプルで最も使用頻度が高いパターンです。
-- SELECT
USING (user_id = (SELECT auth.uid()))
-- INSERT
WITH CHECK (user_id = (SELECT auth.uid()))
-- UPDATE
USING (user_id = (SELECT auth.uid()))
WITH CHECK (user_id = (SELECT auth.uid()))
-- DELETE
USING (user_id = (SELECT auth.uid()))
ユーザー設定やプッシュ通知トークンなど、完全にユーザー個人に紐づくデータに使います。
ポイント: auth.uid() ではなく (SELECT auth.uid())
Supabase の公式ドキュメントでも推奨されていますが、auth.uid() を サブクエリでラップ すると、PostgreSQL のプランナが関数の再評価を避けられるためパフォーマンスが向上します。
-- ❌ 行ごとに auth.uid() が再評価される可能性がある
USING (user_id = auth.uid())
-- ✅ 一度だけ評価される
USING (user_id = (SELECT auth.uid()))
数十行程度のテーブルでは差が出ませんが、数万行以上のテーブルでは目に見える差になります。Storyie では全ポリシーでこの書き方に統一しています。
パターン 2: 公開 or 自分の行を SELECT
投稿やメモのように 公開/非公開の切り替え があるテーブルで使います。
-- SELECT: 公開記事は誰でも読める、非公開は本人だけ
USING (
visibility = 'public'
OR user_id = (SELECT auth.uid())
)
-- INSERT / UPDATE / DELETE: 本人のみ
WITH CHECK (user_id = (SELECT auth.uid()))
visibility カラムを pgEnum で定義し、private / public の 2 値にしています。
なぜ friends や followers_only を作らなかったのか
最初は段階的な公開範囲も検討しましたが、以下の理由でシンプルな 2 値にしました。
- RLS ポリシーの複雑化 — フォロワー判定のサブクエリが入るとパフォーマンスに影響する
- MVP として十分 — 個人の日記アプリでは「公開か非公開か」で事足りる
-
後から追加できる —
pgEnumに値を追加してポリシーを拡張するだけ
RLS 設計では「今必要なものだけ実装する」ことが重要です。過剰な設計はポリシーの見通しを悪くします。
パターン 3: 全員 SELECT + 自分だけ INSERT/DELETE
いいねやコメントなど、誰でも読めるが、書き込みは認証ユーザーに限定 するパターンです。
-- SELECT: 認証済みなら全件閲覧可能
FOR SELECT TO authenticated USING (true)
-- INSERT: 自分のレコードだけ作成可能
FOR INSERT TO authenticated WITH CHECK (user_id = (SELECT auth.uid()))
-- DELETE: 自分のレコードだけ削除可能
FOR DELETE TO authenticated USING (user_id = (SELECT auth.uid()))
TO 句でロールを明示する
TO authenticated を付けることで、匿名ユーザー(anon ロール)からのアクセスを弾けます。Supabase ではデフォルトで anon と authenticated の 2 つのロールが存在するので、これを活用しない手はありません。
コメント系のテーブルでは SELECT を TO public(= 全ロール)にする場合もあります。公開投稿のコメントは未ログインユーザーにも表示したいケースがあるからです。
-- コメント: 未認証ユーザーでも閲覧可能
FOR SELECT TO public USING (true)
-- コメント: 認証済みユーザーのみ投稿可能
FOR INSERT TO authenticated WITH CHECK (author_id = (SELECT auth.uid()))
パターン 4: テナントメンバーシップで制御
マルチテナント構成では、user_id ではなく テナントへの所属 でアクセスを制御します。
-- テナント情報: 自分が所属するテナントのみ閲覧可能
FOR SELECT USING (
EXISTS (
SELECT 1 FROM members
WHERE members.tenant_id = tenants.id
AND members.user_id = (SELECT auth.uid())
)
)
INSERT / UPDATE / DELETE は RLS ではなく、サービスレイヤー(service_role)で制御しています。テナント作成はサインアップフローの一部として実行されるため、クライアントから直接操作する必要がないからです。
IN vs EXISTS の使い分け
テナントメンバーの判定には 2 つの書き方があります。
-- IN: シンプルだが、サブクエリの結果が大きいと遅い
tenant_id IN (
SELECT tenant_id FROM members
WHERE user_id = (SELECT auth.uid())
)
-- EXISTS: 最初の一致で止まるため、テーブルが大きい場合に有利
EXISTS (
SELECT 1 FROM members
WHERE members.tenant_id = tenants.id
AND members.user_id = (SELECT auth.uid())
)
ユーザーあたりのテナント数が少ない(通常 1 つ)場合は IN でも EXISTS でも差はありません。しかし、将来のスケールを考えると EXISTS のほうが安全です。
パターン 5: ロールベースの権限分離
メンバー管理(追加・編集・削除)は、owner または admin ロール を持つメンバーだけに許可します。
-- INSERT: owner/admin のみ新メンバーを追加可能
FOR INSERT WITH CHECK (
tenant_id IN (
SELECT tenant_id FROM members
WHERE user_id = (SELECT auth.uid())
AND role IN ('owner', 'admin')
)
)
再帰参照に注意
メンバーテーブルのポリシーが 自分自身のテーブルを参照 しています。これは PostgreSQL では問題なく動作しますが、テスト時に混乱しやすいポイントです。
「自分のメンバーシップレコードを使って、同じテーブルの別レコードへの権限を判定する」という構造は、理解すると自然ですが、初見では分かりにくいです。コメントでしっかり意図を書いておくことをおすすめします。
パターン 6: service_role 専用
クライアントからは書き込ませたくないが、サーバーサイドの処理(cron ジョブ等)では書き込みが必要なテーブルです。
-- service_role のみ INSERT 可能
FOR INSERT TO service_role WITH CHECK (true)
閲覧ログのようなテーブルがこのパターンです。Edge Function 経由で service_role キーを使って書き込み、定期的な cron ジョブで集計テーブルに反映しています。
なぜクライアントから直接書かないのか
- 不正なデータの水増しを防ぐ — クライアントから INSERT できると、API を直接叩いてデータを操作できてしまう
- バリデーションをサーバーで担保 — 同一ユーザーの短時間内の重複レコード除外など、ビジネスロジックをサーバー側で管理
- RLS ポリシーがシンプルになる — クライアント向けの複雑な条件が不要
パターン 7: INSERT のみ許可(Write-only)
アクティビティログなど、書き込みのみで読み取り不要 なテーブルです。
-- INSERT のみ: 自分のログを書き込める
FOR INSERT WITH CHECK (user_id = (SELECT auth.uid()))
-- SELECT / UPDATE / DELETE のポリシーなし = アクセス不可
RLS が有効なテーブルでは、ポリシーが存在しない操作は 暗黙的に拒否 されます。つまり、INSERT ポリシーだけ定義すれば SELECT / UPDATE / DELETE は自動的にブロックされます。
これは Supabase の「デフォルト拒否」の原則を活かした設計です。ポリシーを追加するほどアクセスが広がるのであって、制限されるのではないという点を理解しておくことが重要です。
設計で意識していること
1. ポリシーはスキーマと一緒に管理する
Drizzle の pgPolicy を使い、テーブル定義とポリシーを同じファイルに書いています。ポリシーが別の場所に散らばると「このテーブルにはどんなアクセス制御があるのか」が分からなくなります。
2. 操作ごとにポリシーを分ける
// ✅ 操作ごとに分離
selectPolicy: pgPolicy("posts_select", { for: "select", ... }),
insertPolicy: pgPolicy("posts_insert", { for: "insert", ... }),
updatePolicy: pgPolicy("posts_update", { for: "update", ... }),
deletePolicy: pgPolicy("posts_delete", { for: "delete", ... }),
1 つのポリシーで全操作をカバーすることもできますが、操作ごとに分けるほうが以下の点で優れています。
- デバッグしやすい — どの操作が拒否されたか特定しやすい
- 変更の影響範囲が明確 — SELECT のルールを変えても INSERT には影響しない
- コードレビューしやすい — 各操作の意図がポリシー名から読み取れる
3. UPDATE には USING と WITH CHECK の両方を書く
-- USING: どの行を更新対象にできるか(WHERE句に近い)
-- WITH CHECK: 更新後の値が条件を満たすか
FOR UPDATE
USING (user_id = (SELECT auth.uid()))
WITH CHECK (user_id = (SELECT auth.uid()))
UPDATE の WITH CHECK を省略すると、USING の条件がそのまま適用されます。しかし、明示的に書くことで「更新後も user_id が変わらないことを保証する」という意図が伝わります。
4. テストは anon / authenticated / service_role の 3 ロールで
ポリシーを追加・変更したら、最低限 3 つのロールでテストします。
-
anon— 未認証ユーザーとしてアクセスした場合 -
authenticated— ログインユーザーとしてアクセスした場合(自分のデータ vs 他人のデータ) -
service_role— サーバーサイド処理としてアクセスした場合(RLS をバイパス)
Supabase Dashboard の SQL エディタで SET ROLE を使えば手軽にテストできます。
まとめ
RLS の設計は「デフォルト拒否」の原則を軸に、ユースケースに合わせてパターンを使い分けることが大切です。
Storyie で 27 テーブル分のポリシーを設計・運用した結果、7 つのパターンに集約できました。最初から完璧な設計は不要で、シンプルなパターンから始めて必要に応じて拡張していくのが実践的です。
重要なのは、ポリシーをコードとして管理し、意図を明文化すること。Drizzle の pgPolicy と組み合わせることで、スキーマ変更とポリシー変更を同じ PR でレビューできるようになり、セキュリティの見通しが格段に良くなります。
Storyie — 日々の出来事を記録・共有できる日記アプリ
- 🌐 Web: https://storyie.com
- 🍎 iOS: App Store
- 🤖 Android Beta: https://storyie.com/android-beta
Discussion