はじめに
マルチテナントSaaSを作るとき、最も怖いのは「他のテナントのデータが見えてしまう」事故だ。
僕は一人法人でAIを活用した業務自動化の会社を経営していて、Next.js + TypeScript + Supabaseでプロダクトを複数運用している。SaaS4つを同時開発した経験があるが、その中でRow Level Security(RLS)の設計をミスって冷や汗をかいたことがある。
この記事では、SupabaseのRLSを使って安全なマルチテナントSaaSを構築する方法を、実体験を交えて解説する。
なぜRLSが必要なのか
アプリケーション層だけでのアクセス制御は危険
典型的なパターンはこうだ。
// ❌ アプリ層だけでテナント制御(危険)
const { data } = await supabase
.from('projects')
.select('*')
.eq('tenant_id', currentUser.tenantId)
一見問題ないが、WHERE句の付け忘れが1箇所でもあれば全テナントのデータが漏れる。開発者が増えるほどリスクは上がる。
RLSはデータベース層でアクセス制御をかけるため、アプリ側でWHERE句を忘れても漏洩しない。
-- ✅ RLSでデータベース層から保護
ALTER TABLE projects ENABLE ROW LEVEL SECURITY;
CREATE POLICY "tenant_isolation" ON projects
USING (tenant_id = auth.jwt() ->> 'tenant_id');
マルチテナントSaaSのRLS設計パターン
1. テーブル設計
まず、全テーブルにtenant_idカラムを持たせる。
CREATE TABLE tenants (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL,
plan TEXT DEFAULT 'free',
created_at TIMESTAMPTZ DEFAULT now()
);
CREATE TABLE profiles (
id UUID PRIMARY KEY REFERENCES auth.users(id),
tenant_id UUID NOT NULL REFERENCES tenants(id),
role TEXT NOT NULL DEFAULT 'member',
display_name TEXT
);
CREATE TABLE projects (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id),
name TEXT NOT NULL,
created_at TIMESTAMPTZ DEFAULT now()
);
CREATE TABLE tasks (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
project_id UUID NOT NULL REFERENCES projects(id),
tenant_id UUID NOT NULL REFERENCES tenants(id),
title TEXT NOT NULL,
status TEXT DEFAULT 'todo',
assigned_to UUID REFERENCES profiles(id)
);
tasksテーブルにもtenant_idを持たせている点がポイントだ。JOINなしでRLSポリシーを評価できるため、パフォーマンスが大幅に向上する。
2. テナントIDの取得関数
JWTのカスタムクレームからテナントIDを取得するヘルパー関数を作る。
CREATE OR REPLACE FUNCTION auth.tenant_id()
RETURNS UUID AS $$
SELECT (auth.jwt() ->> 'tenant_id')::UUID;
$$ LANGUAGE sql STABLE SECURITY DEFINER;
3. RLSポリシーの実装
-- tenants: 自分のテナントのみ参照可能
ALTER TABLE tenants ENABLE ROW LEVEL SECURITY;
CREATE POLICY "tenants_select" ON tenants
FOR SELECT USING (id = auth.tenant_id());
-- profiles: 同一テナント内のメンバーを参照可能
ALTER TABLE profiles ENABLE ROW LEVEL SECURITY;
CREATE POLICY "profiles_select" ON profiles
FOR SELECT USING (tenant_id = auth.tenant_id());
CREATE POLICY "profiles_update_self" ON profiles
FOR UPDATE USING (id = auth.uid());
-- projects: テナント単位でCRUD
ALTER TABLE projects ENABLE ROW LEVEL SECURITY;
CREATE POLICY "projects_select" ON projects
FOR SELECT USING (tenant_id = auth.tenant_id());
CREATE POLICY "projects_insert" ON projects
FOR INSERT WITH CHECK (tenant_id = auth.tenant_id());
CREATE POLICY "projects_update" ON projects
FOR UPDATE USING (tenant_id = auth.tenant_id());
CREATE POLICY "projects_delete" ON projects
FOR DELETE USING (
tenant_id = auth.tenant_id()
AND EXISTS (
SELECT 1 FROM profiles
WHERE profiles.id = auth.uid()
AND profiles.role = 'admin'
)
);
-- tasks: テナント単位でCRUD
ALTER TABLE tasks ENABLE ROW LEVEL SECURITY;
CREATE POLICY "tasks_select" ON tasks
FOR SELECT USING (tenant_id = auth.tenant_id());
CREATE POLICY "tasks_insert" ON tasks
FOR INSERT WITH CHECK (tenant_id = auth.tenant_id());
CREATE POLICY "tasks_update" ON tasks
FOR UPDATE USING (tenant_id = auth.tenant_id());
4. JWTカスタムクレームの設定
Supabaseでサインアップ時にテナントIDをJWTに含める。
CREATE OR REPLACE FUNCTION public.handle_new_user()
RETURNS TRIGGER AS $$
DECLARE
_tenant_id UUID;
BEGIN
-- 招待経由ならメタデータからテナントIDを取得
_tenant_id := (NEW.raw_user_meta_data ->> 'tenant_id')::UUID;
-- 新規テナント作成
IF _tenant_id IS NULL THEN
INSERT INTO tenants (name)
VALUES (COALESCE(NEW.raw_user_meta_data ->> 'company_name', 'My Team'))
RETURNING id INTO _tenant_id;
END IF;
-- プロフィール作成
INSERT INTO profiles (id, tenant_id, role, display_name)
VALUES (
NEW.id,
_tenant_id,
CASE WHEN (NEW.raw_user_meta_data ->> 'tenant_id') IS NULL THEN 'admin' ELSE 'member' END,
COALESCE(NEW.raw_user_meta_data ->> 'display_name', split_part(NEW.email, '@', 1))
);
-- JWTにテナントIDを含める
UPDATE auth.users
SET raw_app_meta_data = raw_app_meta_data || jsonb_build_object('tenant_id', _tenant_id)
WHERE id = NEW.id;
RETURN NEW;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
CREATE TRIGGER on_auth_user_created
AFTER INSERT ON auth.users
FOR EACH ROW EXECUTE FUNCTION public.handle_new_user();
実運用で踏んだ落とし穴
落とし穴1: RLSを有効にしただけでポリシーを作り忘れ
RLSを有効にしてポリシーを定義しないと、全行が拒否される。開発中は動いていたのにステージングで突然データが見えなくなって焦った。
-- RLSを有効にする = デフォルトで全拒否
ALTER TABLE projects ENABLE ROW LEVEL SECURITY;
-- ← ここでポリシーを作らないと、SELECTすら通らない
対策として、マイグレーションファイルではENABLE ROW LEVEL SECURITYとCREATE POLICYを必ずセットで書くルールにした。
落とし穴2: service_roleキーの取り扱い
Supabaseのservice_roleキーはRLSをバイパスする。これをフロントエンドに露出すると全テナントのデータにアクセスできてしまう。
// ❌ 絶対にやってはいけない
const supabase = createClient(url, process.env.NEXT_PUBLIC_SUPABASE_SERVICE_ROLE_KEY!)
// ✅ フロントエンドはanon keyのみ
const supabase = createClient(url, process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!)
service_roleキーはサーバーサイド(API Routes / Server Actions)でのみ使い、管理者向けの操作に限定する。
落とし穴3: JOINを跨いだRLSのパフォーマンス問題
最初はtasksテーブルにtenant_idを持たせず、projectsテーブル経由でテナントを判定していた。
-- ❌ JOINが必要なRLSポリシー(遅い)
CREATE POLICY "tasks_select" ON tasks
FOR SELECT USING (
EXISTS (
SELECT 1 FROM projects
WHERE projects.id = tasks.project_id
AND projects.tenant_id = auth.tenant_id()
)
);
データ量が増えるとクエリが劇的に遅くなった。冗長でもtenant_idを各テーブルに持たせることで解決した。
僕の経験上、10事業を同時運用していたときに「正規化を追求するより実用性を優先すべき」と痛感した。RLSのパフォーマンスも同じで、理想より現実を取るべきだ。
RLSのテスト方法
RLSの設定ミスは致命的なので、テストは必須だ。
-- テスト用: 特定ユーザーとしてクエリを実行
SET request.jwt.claims = '{"sub": "user-uuid-1", "tenant_id": "tenant-uuid-1"}';
SET role = 'authenticated';
-- tenant-uuid-1のデータだけ返ることを確認
SELECT * FROM projects;
-- 別テナントのデータが見えないことを確認
SET request.jwt.claims = '{"sub": "user-uuid-2", "tenant_id": "tenant-uuid-2"}';
SELECT * FROM projects;
-- → tenant-uuid-1のデータが返らないこと
TypeScriptでの自動テストも組む。
import { createClient } from '@supabase/supabase-js'
describe('RLS: tenant isolation', () => {
const tenantAClient = createClient(SUPABASE_URL, ANON_KEY, {
global: { headers: { Authorization: `Bearer ${tenantAToken}` } }
})
const tenantBClient = createClient(SUPABASE_URL, ANON_KEY, {
global: { headers: { Authorization: `Bearer ${tenantBToken}` } }
})
test('テナントAはテナントBのプロジェクトを参照できない', async () => {
const { data } = await tenantAClient
.from('projects')
.select('*')
.eq('tenant_id', tenantBId)
expect(data).toHaveLength(0)
})
test('テナントAは自分のプロジェクトのみ参照できる', async () => {
const { data } = await tenantAClient
.from('projects')
.select('*')
data?.forEach(project => {
expect(project.tenant_id).toBe(tenantAId)
})
})
})
管理者向け機能のRLS設計
SaaSには管理者ダッシュボードが必要になる。テナントの管理者だけが特定の操作をできるようにする。
CREATE OR REPLACE FUNCTION auth.is_tenant_admin()
RETURNS BOOLEAN AS $$
SELECT EXISTS (
SELECT 1 FROM profiles
WHERE id = auth.uid()
AND tenant_id = auth.tenant_id()
AND role = 'admin'
);
$$ LANGUAGE sql STABLE SECURITY DEFINER;
-- メンバー管理: adminのみ
CREATE POLICY "profiles_admin_manage" ON profiles
FOR ALL USING (
tenant_id = auth.tenant_id()
AND auth.is_tenant_admin()
);
チェックリスト
マルチテナントSaaSをリリースする前に確認すべき項目をまとめた。
- 全テーブルでRLSが有効になっているか
- 全テーブルにSELECT/INSERT/UPDATE/DELETEのポリシーがあるか
-
tenant_idカラムにインデックスが貼られているか -
JWTカスタムクレームに
tenant_idが含まれているか -
service_roleキーがフロントエンドに露出していないか - テナント間のデータ隔離テストが通っているか
-
supabase_adminロールの使用箇所が限定されているか - マイグレーションファイルでRLS有効化とポリシー作成がセットになっているか
まとめ
SupabaseのRLSは、マルチテナントSaaSにおけるデータ隔離の最後の砦だ。アプリケーション層のアクセス制御は「あったら便利」だが、RLSは「なければ事故」になる。
僕が複数のSaaSを同時開発した経験から言えるのは、「プロダクト完成と売上は別の話だが、セキュリティ事故が起きたらその両方を失う」ということだ。RLSの設計は最初にしっかりやっておくべき投資だと思う。
この記事が、安全なマルチテナントSaaS構築の参考になれば幸いだ。
筆者はAI経営の実践知を書籍にまとめています。Supabaseを使ったプロダクト開発やAI活用の実践ノウハウに興味がある方は、ぜひご覧ください。
👉 Zenn書籍一覧