Supabase を使い始めて最初に詰まるポイントのひとつが RLS(Row Level Security / 行レベルセキュリティ) です。
「テーブルを作ったのに API から取得すると 0件が返る」「なぜか他のユーザーのデータも見えてしまう」——これらの多くは RLS の設定が正しく理解できていないことが原因です。
この記事では次の順で RLS を解説します。
- RLS とは何か、なぜ有効化すると「全拒否」になるのか
CREATE POLICYの構文と使い方- THINK YOU LAB 本番スキーマの実例(
users/memberships/courses/event_logs) - Service Role Key で RLS をバイパスする場面(Stripe Webhook)
RLS とは何か — なぜ必要か
テーブルの「デフォルト全公開」という罠
Supabase(PostgreSQL)でテーブルを作成した直後、RLS を有効化していない状態では匿名キー(anon key)を使えば誰でも全データを読み書きできます。
// RLS 無効 = 誰でも全件取得できる
const { data } = await supabase.from("users").select("*");フロントエンドには anon key が必ず含まれているため、RLS なしのテーブルは事実上公開されています。
RLS の役割
RLS を有効化すると、デフォルトで全アクセスが拒否されます。 ポリシーを明示的に定義した行だけが、定義した操作(SELECT / INSERT / UPDATE / DELETE)を許可されます。
「デフォルト全拒否」というホワイトリスト方式が RLS の基本思想です。
middleware 認証との役割分担
middleware(proxy.ts)と RLS は別々のレイヤーで動きます。
| レイヤー | 守るもの | 動くタイミング | |----------|----------|--------------| | middleware | ページへのアクセス | HTTP リクエストがサーバーに届いた瞬間 | | RLS | データベースの行 | SQL クエリが実行される瞬間 |
middleware を通過したとしても、DB クエリがユーザーの権限外のデータを取得しようとすれば RLS がブロックします。二層防御の設計です。
ページレベルの認証ガード(middleware)はこちら → Next.js App Router の middleware で Supabase 認証ガードを作る
RLS を有効化する
ALTER TABLE ... ENABLE ROW LEVEL SECURITY; の意味
ALTER TABLE public.users ENABLE ROW LEVEL SECURITY;これを実行した瞬間から、このテーブルへのすべての SELECT / INSERT / UPDATE / DELETE が拒否されます。ポリシーが1件もない状態でもデータを誰も読めなくなります。
THINK YOU LAB では initial_schema.sql のマイグレーションで、全テーブルを一括で有効化しています。
ALTER TABLE public.users ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.plans ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.memberships ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.courses ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.lessons ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.event_logs ENABLE ROW LEVEL SECURITY;RLS を有効化した後、必ず必要なポリシーを定義します。ポリシーがないテーブルはアプリから一切アクセスできません。
ポリシーの書き方
CREATE POLICY の構文
CREATE POLICY "ポリシー名"
ON テーブル名
FOR 操作 (ALL / SELECT / INSERT / UPDATE / DELETE)
USING (条件式) -- SELECT / UPDATE / DELETE に適用
WITH CHECK (条件式); -- INSERT / UPDATE に適用各キーワードの意味:
| キーワード | 説明 |
|-----------|------|
| FOR ALL | SELECT / INSERT / UPDATE / DELETE すべてに適用 |
| FOR SELECT | 読み取りのみ |
| USING (条件) | 既存の行に対する条件。SELECT / UPDATE / DELETE で評価される |
| WITH CHECK (条件) | 新しい行に対する条件。INSERT / UPDATE で評価される |
auth.uid() — ログイン中ユーザーの UUID
USING (auth.uid() = user_id)auth.uid() は現在のリクエストを送ってきているユーザーの UUID を返します。未認証の場合は NULL を返します。
auth.role() — anon / authenticated の切り替え
USING (auth.role() = 'authenticated')auth.role() は現在のユーザーのロールを返します。ログイン済みなら 'authenticated'、未ログインなら 'anon' です。
EXISTS サブクエリで他テーブルの条件を参照する
USING (
EXISTS (SELECT 1 FROM public.users u WHERE u.id = auth.uid() AND u.role = 'admin')
)「users テーブルで自分のレコードを引いたとき、role が 'admin' であれば許可する」という条件です。
THINK YOU LAB での実例
users テーブル — 「本人は全列読み書き」「他会員は display_name のみ」「admin は全件」
-- 本人は自分のレコードを全操作可
CREATE POLICY "users: 本人は全列読み書き"
ON public.users FOR ALL
USING (auth.uid() = id);
-- ログイン済み会員なら他のユーザーの行も SELECT 可(display_name 等を表示するため)
CREATE POLICY "users: 他会員はdisplay_name/avatar_urlのみ読める"
ON public.users FOR SELECT
USING (auth.role() = 'authenticated');
-- admin は全ユーザーを全操作可
CREATE POLICY "users: adminは全件読み書き"
ON public.users FOR ALL
USING (
EXISTS (SELECT 1 FROM public.users u WHERE u.id = auth.uid() AND u.role = 'admin')
);3つのポリシーが重なっています。PostgreSQL RLS はポリシーの OR 評価で動くため、どれか1つが許可すれば操作できます。
memberships テーブル — 「本人は読み取り」「admin は全件」
-- 自分の会員ステータスは自分だけ読める
CREATE POLICY "memberships: 本人は読み取り可"
ON public.memberships FOR SELECT
USING (auth.uid() = user_id);
-- admin は全件操作可(Webhook 経由での更新も含む)
CREATE POLICY "memberships: adminは全件読み書き"
ON public.memberships FOR ALL
USING (
EXISTS (SELECT 1 FROM public.users u WHERE u.id = auth.uid() AND u.role = 'admin')
);memberships は会員の有効・無効・プランを管理する最重要テーブルです。本人は自分のレコードを読むだけで書き込みはできません。Stripe Webhook による更新はアプリサーバーが Service Role Key を使って行います。
courses / lessons — 「active な会員のみ published コースを閲覧可」
-- active ステータスの会員のみ、公開済みコースを閲覧可
CREATE POLICY "courses: 会員はpublishedのみ閲覧可"
ON public.courses FOR SELECT
USING (
is_published = true
AND EXISTS (
SELECT 1 FROM public.memberships m
WHERE m.user_id = auth.uid() AND m.status = 'active'
)
);
CREATE POLICY "courses: adminは全件読み書き"
ON public.courses FOR ALL
USING (
EXISTS (SELECT 1 FROM public.users u WHERE u.id = auth.uid() AND u.role = 'admin')
);この設計で「未払い会員が直接 API を叩いてもコンテンツが返らない」ことを DB レベルで保証します。RLS があれば、API ルートのガードを書き忘れても、DB からデータが出ることはありません。
event_logs — 「INSERT は全員可」「SELECT は admin のみ」
-- 未ログインでも INSERT 可(アクセスログ・行動ログの収集)
CREATE POLICY "event_logs: 全員INSERT可"
ON public.event_logs FOR INSERT
WITH CHECK (true);
-- SELECT は admin のみ(ログの閲覧・集計)
CREATE POLICY "event_logs: adminのみSELECT"
ON public.event_logs FOR SELECT
USING (
EXISTS (SELECT 1 FROM public.users u WHERE u.id = auth.uid() AND u.role = 'admin')
);WITH CHECK (true) は「どんな内容でも書き込み可」という意味です。INSERT を許可しつつ、SELECT は admin にしか許可していないため、蓄積したログが外部に漏れることはありません。
一方で plans テーブルは全公開が必要なため:
-- plans: 全員読み取り可(料金ページは anon からアクセスされる)
CREATE POLICY "plans: 全員読み取り可"
ON public.plans FOR SELECT
USING (true);USING (true) は「すべての行を SELECT 可」という意味です。pricing ページはログイン前のユーザーも閲覧するため、anon キーから料金プランのデータを取得できる必要があります。
Service Role Key — RLS をバイパスする場面
Service Role Key とは
| キー | 用途 | RLS |
|------|------|-----|
| anon key | フロントエンド・API ルートで使用 | RLS が適用される |
| service role key | サーバーサイドのみ(Webhook・バッチ処理) | RLS をバイパスする |
Stripe Webhook での使用例
Stripe が決済完了を通知してくる Webhook リクエストには Supabase の認証情報(Cookie)がありません。通常の anon クライアントを使って memberships テーブルを更新しようとしても RLS がブロックします。
// /api/webhooks/stripe/route.ts
import { createClient } from "@supabase/supabase-js";
// Service Role Key でクライアント作成 → RLS バイパス
const supabase = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY!
);
await supabase
.from("memberships")
.update({ status: "active" })
.eq("user_id", userId);Service Role Key の保管場所と禁止事項
# .env.local(絶対にクライアントサイドに公開しない)
SUPABASE_SERVICE_ROLE_KEY=eyJhb...
NEXT_PUBLIC_ プレフィックスを絶対につけてはいけません。 ブラウザに露出すると誰でも全データを読み書き・削除できる状態になります。
よくある詰まりポイント
RLS 有効化後に API が 0件を返すようになった
原因: ポリシーが設定されていない
-- Supabase SQL Editor で実行
SELECT * FROM pg_policies WHERE tablename = 'users';ポリシーが0件なら必要なポリシーを追加してください。
anon キーでも読めてしまう
原因: USING (true) のポリシーが設定されている(意図せず全公開になっている)
plans テーブルのように意図的に全公開するケースを除き、USING (true) は使わないようにします。
auth.uid() が NULL になる
原因の多くは以下のいずれか:
- Cookie が正しく設定されていない
- middleware(
proxy.ts)が Cookie のリフレッシュに失敗している - Webhook や管理処理で anon key を使っている(Service Role Key を使うべき箇所)
まとめ
-
RLS 有効化 = デフォルト全拒否。ポリシーを定義した行だけが操作可能になる
-
USINGは既存行への条件(SELECT / UPDATE / DELETE)、WITH CHECKは新規行への条件(INSERT / UPDATE) -
auth.uid()でログイン中ユーザーを識別、auth.role()で anon / authenticated を区別する -
admin 判定は
EXISTS (SELECT 1 FROM users WHERE ... AND role = 'admin')で実装する -
Service Role Key は RLS をバイパスする強力なキー。サーバーサイドのみで使い、絶対にフロントエンドに露出させない
-
Service Role を使う Stripe Webhook の実装はこちら → Supabase + Stripe Webhook で会員ステータスを自動更新する
-
ページレベルの認証ガード(middleware)はこちら → Next.js App Router の middleware で Supabase 認証ガードを作る
RLS を書けるようになると「誰が何のデータにアクセスできるか」を DB 側で制御できます。RLS ポリシーのパターン集(読み取り専用/ユーザー分離/管理者アクセス)を LINE で無料配布中です。
→ Supabase RLS ポリシーテンプレを受け取る(LINE登録・無料)
LINE 公式アカウント
Supabase RLS ポリシーテンプレート集をLINEで無料配布中