auth.uid() / auth.jwt() を使い、JWTベースでユーザー単位のアクセス制御を実現するSQLのデータ構造はスプレッドシートに近い。テーブル = シート、行 = 1件のレコード、列 = フィールド定義。データ操作はCRUD(Create / Read / Update / Delete)の4つに集約される。
| 操作 | SQL | Django ORM 相当 |
|---|---|---|
| Create | INSERT INTO users (name, email) VALUES ('Mickey', 'a@b.com'); | User.objects.create(...) |
| Read | SELECT name, email FROM users; | User.objects.all() |
| Update | UPDATE users SET name = 'M' WHERE id = 1; | user.save() |
| Delete | DELETE FROM users WHERE id = 1; | user.delete() |
CREATE TABLE users (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL,
email TEXT UNIQUE NOT NULL
);
INSERT INTO users (name, email) VALUES ('Mickey', 'mickey@example.com');
SELECT * FROM users;
💡 ポイント:
SELECT *は全列を返すが、本番では必要な列だけ指定する。Supabaseの.select('name, email')も同じ思想。
WHERE句は「どの行を対象にするか」を指定するフィルタ。RLSの本質は「このWHEREをDBが自動で付加する仕組み」に他ならない。
-- 開発者が書くクエリ
SELECT * FROM posts;
-- RLSが有効だと、DBが内部的にこう変換する
SELECT * FROM posts WHERE user_id = '現在のユーザーID';
WHERE句は SELECT / UPDATE / DELETE すべてに使え、AND / OR / NOT で条件を組み合わせられる。Django ORMの .filter() / .exclude() がWHERE句に変換されている。
💡 ポイント: クライアント向けの説明では「RLSはデータベースが自動で付けるWHERE句です」と言い切ると伝わりやすい。
PostgreSQLは「ロール」単位でテーブルへの操作権限を制御する。GRANTで許可を与え、REVOKEで取り消す。
CREATE ROLE app_user LOGIN PASSWORD 'secret';
GRANT SELECT, INSERT ON users TO app_user;
REVOKE SELECT ON users FROM app_user;
| オプション | 意味 | デフォルト |
|---|---|---|
LOGIN | DB接続を許可 | NOLOGIN |
PASSWORD | 接続時のパスワード | なし |
SUPERUSER | 全権限 | NOSUPERUSER |
GRANTは「テーブルにアクセスできるか」を制御し、RLSは「テーブル内のどの行にアクセスできるか」を制御する。両者は別レイヤー。
Supabaseとの対応:
| PostgreSQLロール | Supabase上の意味 | 用途 |
|---|---|---|
anon | 未ログインユーザー | 公開データの読み取り |
authenticated | ログイン済みユーザー | 自分のデータのCRUD |
service_role | サーバーサイド管理者 | RLSをバイパスして全データにアクセス |
💡 ポイント: 「GRANTは建物の入館証、RLSは部屋ごとの鍵」と例えると直感的。
RLSとは別レイヤーで、テーブル定義(DDL)の制約でカラムの値を制御する。
CREATE TABLE tasks (
id SERIAL PRIMARY KEY,
owner UUID NOT NULL,
title TEXT NOT NULL,
status TEXT CHECK (status IN ('draft', 'published', 'archived')),
price INTEGER CHECK (price >= 0),
email TEXT UNIQUE NOT NULL,
user_id UUID REFERENCES auth.users(id)
);
| PostgreSQL | Django | 意味 |
|---|---|---|
NOT NULL | blank=False, null=False | NULL禁止 |
UNIQUE | unique=True | 重複禁止 |
CHECK (price >= 0) | MinValueValidator(0) | 値の範囲制限 |
CHECK (status IN (...)) | choices=[...] | 選択肢制限 |
DEFAULT 'draft' | default='draft' | デフォルト値 |
REFERENCES | ForeignKey(...) | 外部キー |
アプリ層(Django等)だけでアクセス制御する場合、以下のリスクがある:
.filter(user=request.user) を書き忘れると他人のデータが返るRLSがある場合、どんなクエリでも自動フィルタが適用される:
SELECT * FROM posts;
-- → 内部的に WHERE user_id = auth.uid() が強制付加
防御の多層化(Defense in Depth):
フロントエンド バリデーション ← 突破されうる
APIミドルウェア / 認証 ← 突破されうる
アプリ層 ORM フィルタ ← 書き忘れうる
RLS(DBレベル強制フィルタ) ← 最後の砦 ★
GRANT(テーブル単位の権限) ← 基盤
💡 ポイント: 「アプリ層のフィルタは"やるべきこと"、RLSは"やらなくても安全であること"を保証する仕組み。セキュリティは人間の注意力に依存させてはいけない。」
RLSは「テーブルに定義されたポリシー(条件式)を、全てのクエリに自動でWHERE句として付加する」仕組み。
-- ステップ1: RLS有効化(デフォルト拒否になる)
ALTER TABLE posts ENABLE ROW LEVEL SECURITY;
-- ステップ2: ポリシーを作成
CREATE POLICY "自分の投稿だけ見える"
ON posts FOR SELECT
USING (user_id = auth.uid());
USING と WITH CHECK の違い(最重要):
| 句 | 適用される操作 | 役割 |
|---|---|---|
USING | SELECT, UPDATE(既存行), DELETE | 「どの行が見えるか / 操作対象になるか」 |
WITH CHECK | INSERT, UPDATE(変更後の行) | 「この行を作成/変更してよいか」 |
CREATE POLICY "自分の投稿CRUD"
ON posts FOR ALL
USING (user_id = auth.uid()) -- 既存行: 自分のだけ見える
WITH CHECK (user_id = auth.uid()); -- 新規/更新: 自分のIDでしか作れない
💡 ポイント: 「USINGは"読み取りの門番"、WITH CHECKは"書き込みの門番"。両方セットで定義しないとガードに穴が開く。」
-- RLS有効化
ALTER TABLE posts ENABLE ROW LEVEL SECURITY;
-- ポリシー作成
CREATE POLICY "select_own" ON posts FOR SELECT
TO authenticated
USING (user_id = auth.uid());
-- 管理コマンド
SELECT * FROM pg_policies WHERE tablename = 'posts'; -- 一覧確認
DROP POLICY "select_own" ON posts; -- 削除
ALTER TABLE posts DISABLE ROW LEVEL SECURITY; -- 無効化
ALTER TABLE posts FORCE ROW LEVEL SECURITY; -- オーナーにも適用
ありがちな事故:
ENABLE だけしてポリシーを作らない → 全員アクセス不可ENABLE を忘れてポリシーだけ作る → ポリシーが適用されないPostgreSQL素のRLSではcurrent_userがポリシーの判定基準になる。
CREATE ROLE alice LOGIN PASSWORD 'pass_alice';
CREATE ROLE bob LOGIN PASSWORD 'pass_bob';
CREATE TABLE notes (
id SERIAL PRIMARY KEY, owner TEXT NOT NULL, content TEXT NOT NULL
);
INSERT INTO notes (owner, content) VALUES ('alice', 'Aliceのメモ');
INSERT INTO notes (owner, content) VALUES ('bob', 'Bobのメモ');
GRANT SELECT, INSERT, UPDATE, DELETE ON notes TO alice;
GRANT SELECT, INSERT, UPDATE, DELETE ON notes TO bob;
ALTER TABLE notes ENABLE ROW LEVEL SECURITY;
CREATE POLICY "see_own" ON notes FOR SELECT USING (owner = current_user);
-- aliceで接続: SELECT * FROM notes; → Aliceのメモだけ返る
-- bobで接続: SELECT * FROM notes; → Bobのメモだけ返る
current_user vs auth.uid():
| PostgreSQL素 | Supabase | |
|---|---|---|
| ユーザー識別 | current_user(ロール名) | auth.uid()(JWTのsub) |
| ロール名 | ユーザーごとに異なる | 全員 authenticated |
| ポリシー例 | owner = current_user | user_id = auth.uid() |
操作ごとにポリシーを分けて定義するのが基本。FOR ALLは条件を操作別に変えたい時に対応できない。
CREATE POLICY "select_tasks" ON tasks FOR SELECT
USING (owner = current_user OR is_public = true);
CREATE POLICY "insert_tasks" ON tasks FOR INSERT
WITH CHECK (owner = current_user);
CREATE POLICY "update_tasks" ON tasks FOR UPDATE
USING (owner = current_user)
WITH CHECK (owner = current_user);
CREATE POLICY "delete_tasks" ON tasks FOR DELETE
USING (owner = current_user);
| 操作 | USING | WITH CHECK | 設計意図 |
|---|---|---|---|
| SELECT | owner = current_user OR is_public | — | 自分の + 公開を見せる |
| INSERT | — | owner = current_user | なりすまし防止 |
| UPDATE | owner = current_user | owner = current_user | 自分のだけ変更、owner書き換え防止 |
| DELETE | owner = current_user | — | 自分のだけ削除 |
💡 ポイント:
FOR ALLは使わず、操作ごとに分けて書く。意図が明示的になり、レビューしやすく、穴も見つけやすい。
同じテーブルに複数ポリシーが存在する場合、PERMISSIVEは「OR結合」、RESTRICTIVEは「AND結合」で動作する。
最終結果 = (PERMISSIVE_1 OR PERMISSIVE_2 OR ...)
AND
(RESTRICTIVE_1 AND RESTRICTIVE_2 AND ...)
-- PERMISSIVE: アクセスを「許可」する条件
CREATE POLICY "own" ON tasks FOR SELECT AS PERMISSIVE
USING (owner = current_user);
CREATE POLICY "public" ON tasks FOR SELECT AS PERMISSIVE
USING (is_public = true);
-- RESTRICTIVE: 全体に共通する「制限」
CREATE POLICY "not_archived" ON tasks FOR SELECT AS RESTRICTIVE
USING (archived = false);
-- 結果: (own OR public) AND (not_archived)
| ユースケース | 種類 | 理由 |
|---|---|---|
| 自分のデータを見せる | PERMISSIVE | アクセスを許可する条件 |
| 公開データを見せる | PERMISSIVE | 別の許可条件を追加 |
| 論理削除データを除外 | RESTRICTIVE | 全ポリシーに共通する制約 |
💡 ポイント: 迷ったらPERMISSIVEだけで始めてよい。RESTRICTIVEは「論理削除」「メンテナンスモード」など横断的制約が出てきた時に検討。
Supabaseでは全リクエストがJWTを持ち、JWT内のroleクレームでPostgreSQLのロールが切り替わる。
未ログイン → anon key → PostgreSQLロール: anon
ログイン済み → access token (JWT) → PostgreSQLロール: authenticated
サーバー側 → service_role key → RLSを完全バイパス
JWTの中身:
{
"sub": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"email": "alice@example.com",
"role": "authenticated",
"app_metadata": { "provider": "email" },
"user_metadata": { "name": "Alice" }
}
auth.uid() は魔法ではなく、PostgreSQLの current_setting('request.jwt.claims') のラッパー関数。JWTのsubクレームをセッション変数から読んでいるだけ。
| キー | 公開可否 | RLS | 用途 |
|---|---|---|---|
anon key | 公開OK(NEXT_PUBLIC_可) | 適用される | フロントからの公開データ取得 |
access token | ユーザーごとに発行 | 適用される | ログイン後の個人データ操作 |
service_role key | 絶対非公開 | バイパス | サーバー側の管理処理 |
-- auth.uid(): ユーザーIDをUUIDで返す
SELECT auth.uid(); -- → 'a1b2c3d4-...'
-- auth.jwt(): JWT全体をJSONで返す
SELECT auth.jwt()->>'email'; -- → 'alice@example.com'
パターン1: 基本CRUD(最頻出)
CREATE POLICY "select_own" ON profiles FOR SELECT
USING (id = auth.uid());
CREATE POLICY "update_own" ON profiles FOR UPDATE
USING (id = auth.uid()) WITH CHECK (id = auth.uid());
パターン2: 公開 + 所有者
CREATE POLICY "public_read" ON posts FOR SELECT
TO anon, authenticated
USING (status = 'published');
CREATE POLICY "own_read" ON posts FOR SELECT
TO authenticated
USING (user_id = auth.uid());
パターン3: JWTカスタムクレームでロール制御
CREATE POLICY "admin_read_all" ON posts FOR SELECT
TO authenticated
USING (auth.jwt()->'app_metadata'->>'role' = 'admin');
パターン4: マルチテナント(組織ベース)
CREATE POLICY "org_read" ON org_documents FOR SELECT
TO authenticated
USING (
org_id IN (
SELECT org_id FROM org_members WHERE user_id = auth.uid()
)
);
USINGにはどんな条件でも書ける(TRUEかFALSEを返す式なら何でもOK):
| パターン | USING例 | ユースケース |
|---|---|---|
| 所有者のみ | user_id = auth.uid() | マイページ |
| 所有者 + 公開 | user_id = auth.uid() OR is_public | ブログ、SNS |
| ステータス条件 | status = 'published' | CMS |
| 時間条件 | publish_date <= now() | 予約公開 |
| 関連テーブル参照 | team_id IN (SELECT ...) | マルチテナント |
| JWTクレーム参照 | auth.jwt()->>'xxx' | ロールベースアクセス |
// フロント側は何もフィルタしない。RLSが自動で効く
const { data: posts } = await supabase.from('posts').select('*');
💡 ポイント: 「フロントのコードには一切のアクセス制御ロジックを書かない。全てDBポリシーに集約する。APIが増えてもモバイルアプリを追加しても、同じセキュリティが自動適用される。」
DashboardのGUIは学習・確認用として優秀だが、本番運用ではSQL直書き + マイグレーション管理が必須。
| 観点 | GUI | SQL |
|---|---|---|
| PERMISSIVE/RESTRICTIVE | ❌ | ✅ |
| バージョン管理 | ❌ | ✅ Git管理可能 |
| 環境間の移行 | ❌ 手作業 | ✅ マイグレーション |
推奨ワークフロー:
supabase migration new add_posts_rls
# → supabase/migrations/20240101000000_add_posts_rls.sql が生成
supabase db reset # ローカルで検証
supabase db push # 本番に適用
プロジェクト構成:
my-project/
├── supabase/
│ ├── migrations/
│ │ ├── 20240101000000_create_tables.sql
│ │ └── 20240101000001_add_posts_rls.sql ★
│ └── seed.sql
├── src/app/
└── .env.local
💡 ポイント: 「RLSポリシーはインフラのセキュリティ設定と同等の重要度。コードとしてGit管理し、レビュー・テスト・ロールバックを可能にすべき。」
| パターンA (user_id) | パターンB (org_id) | |
|---|---|---|
| 構造 | テーブルにuser_id列 | 中間テーブル(org_members) |
| ポリシーの複雑さ | 低い | サブクエリが必要 |
| 権限の粒度 | 本人 or 全員 | ロール別(owner/admin/member) |
| 1ユーザー複数テナント | ❌ | ✅ |
| 適するアプリ | 個人向け、C2C | B2B SaaS、チームツール |
パターンA:
CREATE POLICY "own_todos" ON todos FOR ALL
TO authenticated
USING (user_id = auth.uid())
WITH CHECK (user_id = auth.uid());
パターンB:
CREATE POLICY "org_read" ON projects FOR SELECT
TO authenticated
USING (
org_id IN (
SELECT org_id FROM org_members WHERE user_id = auth.uid()
)
);
💡 ポイント: 「現時点の要件ではA、ただしチーム機能のロードマップがあるならBを推奨」と選択肢を示すのがベスト。
RLSは「誰がどの行にアクセスできるか」を担い、アプリ層は「ビジネスロジックの検証」を担う。
RLSが担うこと:
✅ この行にアクセスできるか(認可)
✅ 自分 / 所属組織のデータだけ見えるか
✅ ロールに応じた操作制限
アプリ層が担うこと:
✅ 入力値のフォーマット検証
✅ ビジネスルール(「月5件まで」「残高が足りるか」)
✅ 複数テーブルにまたがるトランザクション整合性
✅ 外部API連携
✅ ユーザーへのエラーメッセージ
判断フロー:
「誰がどの行にアクセスできるか」→ RLS
「それ以外」→ アプリ層(Zod / Hono / Server Action)
service_role keyは「RLSを完全に無視してDBの全データにアクセスできるマスターキー」。漏洩 = 全データ流出。
安全な運用ルール:
// lib/supabase/admin.ts
import { createClient } from '@supabase/supabase-js';
import 'server-only'; // ← Client Componentからインポート時にビルドエラー
export const supabaseAdmin = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY! // NEXT_PUBLIC_ 絶対禁止
);
| client.ts | admin.ts | |
|---|---|---|
| キー | anon key | service_role key |
| RLS | 適用 | バイパス |
| 使用場所 | Client/Server Component | Server Action / API Routeのみ |
NEXT_PUBLIC_ | ✅ つけてOK | ❌ 絶対つけない |
server-only | 不要 | 必須 |
環境ごとのキー管理:
| 環境 | キーの保管場所 | 備考 |
|---|---|---|
| ローカル | .env.local(Git管理外) | supabase startで自動生成 |
| 検証/staging | Vercelの環境変数(Preview) | 本番とは別プロジェクト |
| 本番 | Vercelの環境変数(Production) | アクセスできる人を最小限に |
ローカル開発は supabase start を使えばクラウドの枠を消費せず、かつ本番データに触れないので安全。
💡 ポイント: 「service_role keyの漏洩は、DBのrootパスワードが漏洩したのと同義。
server-onlyパッケージの導入とCI/CDでの環境変数チェックを必須ルールとすべき。」
RLSポリシーは内部的にWHERE句として付加されるため、参照列にインデックスがないとフルテーブルスキャンが発生する。
| ポリシーのUSING句 | 必要なインデックス |
|---|---|
user_id = auth.uid() | CREATE INDEX ON posts (user_id); |
org_id IN (SELECT ... WHERE user_id = ...) | CREATE INDEX ON org_members (user_id); |
user_id = auth.uid() AND status = 'published' | CREATE INDEX ON posts (user_id, status); |
サブクエリポリシーの最適化(SECURITY DEFINER関数):
CREATE OR REPLACE FUNCTION get_my_org_ids()
RETURNS SETOF UUID
LANGUAGE sql SECURITY DEFINER STABLE
AS $$
SELECT org_id FROM org_members WHERE user_id = auth.uid();
$$;
CREATE POLICY "org_read" ON projects FOR SELECT
USING (org_id IN (SELECT get_my_org_ids()));
STABLEにより1リクエスト内でキャッシュされ、SECURITY DEFINERにより関数内はRLSの影響を受けない。
| 症状 | 原因 | 確認方法 | 対処 |
|---|---|---|---|
| 全データ見える | RLS未有効化 | pg_tables.rowsecurity = false | ALTER TABLE ... ENABLE RLS |
| 全データ見える | service_role接続 | クライアント初期化コード確認 | anon keyに変更 |
| 全データ見える | テーブルオーナー接続 | SELECT current_user | FORCE ROW LEVEL SECURITY |
| 何も見えない | ポリシー未作成 | pg_policiesが空 | ポリシーを作成 |
| 何も見えない | auth.uid()がNULL | SELECT auth.uid() | 認証状態を確認 |
| INSERTが弾かれる | WITH CHECK未設定 | pg_policies.with_check | WITH CHECKを見直す |
診断クエリ:
-- RLS有効か確認
SELECT tablename, rowsecurity FROM pg_tables WHERE schemaname = 'public';
-- ポリシー一覧
SELECT tablename, policyname, permissive, roles, cmd, qual, with_check
FROM pg_policies WHERE schemaname = 'public';
-- auth.uid()の値確認
SELECT auth.uid();
-- 特定ロールとしてテスト
SET ROLE authenticated;
SET request.jwt.claims = '{"sub": "test-uuid-here"}';
SELECT * FROM posts;
RESET ROLE;
⚠️ Dashboard SQL Editorの罠: SQL Editorはpostgres(スーパーユーザー)で実行されるため、RLSをバイパスする。SET ROLEでテストするか、フロントから実測する。
SET ROLE authenticated;
SET request.jwt.claims = '{"sub": "uuid-alice"}';
EXPLAIN ANALYZE SELECT * FROM posts WHERE status = 'published';
RESET ROLE;
読み方:
✅ Index Scan → インデックスを使っている(高速)
✅ Index Only Scan → インデックスだけで完結(最速)
❌ Seq Scan → 全行スキャン(データ量に比例して遅い)
❌ Rows Removed by Filter: 大きい数字 → 無駄に読んでいる
💡 ポイント: 「RLSポリシーの追加は機能変更であると同時にパフォーマンス変更でもある。PRにはEXPLAIN ANALYZEの結果を添付するルールを推奨。」
| # | ミス | 原因 | 対策 |
|---|---|---|---|
| 1 | RLS有効化せずポリシーだけ作成 | ENABLEを忘れた | pg_tables.rowsecurityで確認 |
| 2 | ENABLE後にポリシー未作成 | あとで作ろうと思った | ENABLE直後にポリシーをセットで作成 |
| 3 | WITH CHECK未設定 | USINGだけで十分と思った | SELECT以外は必ずWITH CHECKを書く |
| 4 | service_role keyをフロントに露出 | NEXT_PUBLIC_をつけた | server-onlyパッケージ + 命名規則 |
| 5 | Dashboard SQL Editorで「RLS効いてない」と誤判断 | postgresロールでテスト | SET ROLEでテスト or フロントから実測 |
| 6 | FOR ALLで一括定義 | コード量を減らしたかった | 操作別にポリシーを分けて定義 |
| 7 | ポリシー参照列にインデックスなし | パフォーマンス未考慮 | ポリシー追加時にインデックスもセット |
| 8 | サブクエリポリシーのN+1 | 中間テーブルの設計不備 | SECURITY DEFINER関数 + STABLE |
| 9 | anonロールへのポリシー漏れ | TOを省略してPUBLICに適用 | TO anon / TO authenticatedを明示 |
| 10 | マイグレーション管理なし | GUIだけで運用 | Supabase CLI + Gitで管理 |
本番リリース前チェックリスト:
.filter() の書き忘れが直接データ漏洩になる。RLSはこの構造的弱点を補完するCREATE ROLE ... LOGIN PASSWORD はDBへの接続手段の定義。Supabase環境ではJWTが代替するので、自分でロール作成する場面はほぼないNEXT_PUBLIC_ 付与は最も危険なミス。server-only パッケージで構造的に防止するsupabase start でクラウドの枠を消費せず安全に検証できる