〜 Next.js + Supabase アーキテクチャにおける現実解 〜
はじめに
PostgreSQL の Row Level Security (RLS) は、データベースレベルでアクセス制御を実現する強力な機能です。Supabase を使った開発では「RLS を有効化すべし」という声をよく耳にします。
しかし、私たちの SaaS プロダクト「Founders Direct Cockpit(FDC)」では、あえて RLS を使用していません。
本記事では、その理由と、RLS に代わるセキュリティアーキテクチャについて解説します。
TL;DR
| 観点 | RLS あり | RLS なし(我々の選択) |
|---|---|---|
| 防御レイヤー | DB 層 + アプリ層 | アプリ層のみ |
| 複雑さ | 二重管理が必要 | シンプル |
| デバッグ | 切り分け困難 | 明確 |
| 開発速度 | やや遅い | 速い |
| 適用範囲 | 全アクセス | API 経由のみ |
結論: クライアントから直接 Supabase にアクセスしないアーキテクチャでは、RLS は必須ではない。
アーキテクチャ
データアクセスフロー
┌──────────────────┐
│ Browser │
│ (Next.js 15) │
│ React 19 │
└────────┬─────────┘
│
│ API Call
│ Cookie: fdc_session
│ (HttpOnly, Secure, SameSite=Lax)
│
▼
┌───────────────────────┐
│ Next.js API Routes │ ← ここで認証・認可
│ (app/api/**/route.ts)│
└──────────┬────────────┘
│
│ SERVICE_ROLE_KEY
│ (RLS バイパス)
│
▼
┌──────────────────────┐
│ Supabase PostgreSQL │
│ (RLS は無効) │
└──────────────────────┘
ポイント
-
ブラウザから Supabase に直接アクセスしない
- すべての DB 操作は Next.js API Routes 経由
-
ANON_KEYはクライアント認証のみに使用
-
SERVICE_ROLE_KEY でアクセス
- RLS を自動的にバイパス
- サーバーサイドでのみ使用(クライアントに露出しない)
-
認証・認可は Next.js で実装
- HttpOnly Cookie によるセッション管理
- API Route 内でワークスペースメンバーシップ検証
RLS を使わない理由
1. アーキテクチャとの不一致
RLS は「クライアントから直接 DB にアクセスする」構成で真価を発揮します。
// Supabase の典型的な使い方(RLS が有効な場合)
const { data } = await supabase
.from('todos')
.select('*')
.eq('user_id', userId);
// → RLS ポリシーが自動適用され、他ユーザーのデータは見えない
しかし、今回の構成では:
//FDCのアーキテクチャ
// 1. Browser → API Route(Cookie 認証)
// 2. API Route → Supabase(SERVICE_ROLE_KEY)
// api/workspaces/[workspaceId]/data/route.ts
export async function GET(request: NextRequest) {
// 1. 認証チェック
const user = await getSession(request);
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
// 2. 認可チェック(ワークスペースメンバーシップ)
const membership = await getWorkspaceMembership(workspaceId, user.id);
if (!membership) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
// 3. データ取得(SERVICE_ROLE_KEY でアクセス、RLS バイパス)
const { data } = await supabase
.from('workspace_data')
.select('*')
.eq('workspace_id', workspaceId);
return NextResponse.json(data);
}
SERVICE_ROLE_KEY を使う限り、RLS は無意味です。
中途半端に RLS を ON にしても、バイパスされるだけ。
2. 二重管理の複雑さ
RLS を有効にすると、認可ロジックが二箇所に分散します:
| レイヤー | 責務 | 実装場所 |
|---|---|---|
| アプリ層 | ビジネスロジック認可 | lib/server/auth.ts |
| DB 層 | 行レベル認可 | migrations/rls-policies.sql |
問題点
-
同期の維持が困難
- アプリ層で「ADMIN 以上が編集可能」と実装
- RLS で「OWNER のみ編集可能」と設定
- → 不整合が発生しても気づきにくい
-
デバッグの困難さ
- 「データが取得できない」問題が発生した時
- 原因が「RLS ポリシー」なのか「アプリの認可ロジック」なのか切り分けが難しい
-
テストの複雑化
- RLS ポリシーのテストは SQL レベルで行う必要がある
- アプリ層のテストとは別に管理
3. 開発速度への影響
新しいテーブルを追加するたびに:
RLS あり:
- テーブル作成
- RLS ポリシー設計
- RLS ポリシー実装(SQL)
- RLS ポリシーテスト
- アプリ層の認可ロジック実装
- アプリ層のテスト
RLS なし:
- テーブル作成
- アプリ層の認可ロジック実装
- アプリ層のテスト
ステップが半減します。スタートアップにとって、この差は大きい。
4. Service Role Key の存在
Supabase を使う限り、SERVICE_ROLE_KEY は必要です:
- 管理者機能(全ユーザー一覧の取得など)
- バッチ処理
- マイグレーション
- データ移行
これらの処理では RLS をバイパスする必要があります。
結局、「RLS をバイパスできるキー」が存在する以上、
RLS は「唯一の防御線」にはなりえません。
RLS の代わりに何をしているか
多層防御アーキテクチャ
┌─────────────────────────────────────────────────────────────────┐
│ セキュリティレイヤー │
├─────────────────────────────────────────────────────────────────┤
│ │
│ 1. 認証(Authentication) │
│ - HttpOnly Cookie によるセッション管理 │
│ - Google OAuth 2.0 認証 │
│ - セッションキャッシュ(Vercel KV、TTL 5分) │
│ │
│ 2. 認可(Authorization) │
│ - ワークスペースメンバーシップ検証 │
│ - ロールベースアクセス制御(OWNER/ADMIN/MEMBER) │
│ - lib/utils/permissions.ts で権限チェック関数を集約 │
│ │
│ 3. レート制限 │
│ - Sliding Window Counter 方式 │
│ - エンドポイント別制限(AI: 5req/min、通常: 60req/min) │
│ │
│ 4. 入力バリデーション │
│ - Zod によるスキーマバリデーション │
│ - XSS / SQL インジェクション対策 │
│ │
│ 5. 暗号化 │
│ - AES-256-GCM による機密データ暗号化 │
│ - TLS 1.2+ による通信暗号化 │
│ │
│ 6. 監査ログ │
│ - すべての重要操作を audit_logs テーブルに記録 │
│ - Pino 構造化ログ(機密情報自動マスキング) │
│ │
└─────────────────────────────────────────────────────────────────┘
実装例
認可チェック関数の集約
// lib/utils/permissions.ts
export function canEdit(role: string): boolean {
return ['OWNER', 'ADMIN', 'MEMBER'].includes(role);
}
export function canManageMembers(role: string): boolean {
return ['OWNER', 'ADMIN'].includes(role);
}
export function canDeleteWorkspace(role: string): boolean {
return role === 'OWNER';
}
API Route での認可実装
// app/api/workspaces/[workspaceId]/members/route.ts
export async function POST(request: NextRequest) {
// 1. 認証
const user = await getSession(request);
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
// 2. メンバーシップ検証
const membership = await getWorkspaceMembership(workspaceId, user.id);
if (!membership) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
// 3. ロール権限チェック
if (!canManageMembers(membership.role)) {
return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 });
}
// 4. 処理実行
// ...
}
マルチテナント分離
// すべての業務リポジトリで tenant_id + workspace_id を必須パラメータとする
type TenantAwareParams = {
tenantId: string;
workspaceId: string;
userId: string;
};
export async function listTodos({ tenantId, workspaceId, userId }: TenantAwareParams) {
// 1. メンバーシップ検証
await verifyMembership(workspaceId, userId);
// 2. tenant_id + workspace_id でフィルタリング
return db
.selectFrom('todos')
.where('tenant_id', '=', tenantId)
.where('workspace_id', '=', workspaceId)
.execute();
}
RLS を導入すべきタイミング
我々は「永久に RLS を使わない」とは言っていません。
以下の条件で RLS 導入を検討します:
1. Realtime Subscriptions の導入時
// クライアントから直接 Supabase Realtime を使う場合
const channel = supabase
.channel('todos')
.on('postgres_changes', { event: '*', schema: 'public', table: 'todos' }, (payload) => {
// → RLS がないと、全ユーザーの変更が見えてしまう!
})
.subscribe();
2. anon key を使った直接クエリの導入時
// ブラウザから直接 Supabase にクエリする場合
const { data } = await supabase
.from('todos')
.select('*');
// → RLS がないと、全ユーザーのデータが取得可能!
3. 多層防御が監査要件で求められた時
外部監査で「DB レベルのアクセス制御」を要求された場合。
まとめ
| 観点 | 我々の判断 |
|---|---|
| RLS の価値 | 認める(ただし、特定のアーキテクチャで) |
| 現状の必要性 | なし(SERVICE_ROLE_KEY 経由のため) |
| 代替策 | アプリ層での多層防御 |
| 将来の導入 | トリガー条件を明確化して待機 |
RLS は「銀の弾丸」ではありません。
アーキテクチャに応じた適切なセキュリティ設計が重要です。
我々の場合、Next.js API Routes による BFF パターンが主軸であり、
RLS よりも「アプリ層での厳格な認可」の方が合理的でした。
補足:RLS を使うべきケース
以下のような構成では、RLS を積極的に使うべきです:
-
クライアントから直接 Supabase にクエリする構成
- モバイルアプリ(React Native + Supabase)
- Supabase Realtime を活用するリアルタイムアプリ
-
マルチテナント SaaS で Defense in Depth を重視する場合
- 外部開発者がコードに触れる環境
- セキュリティ監査で DB レベル制御を要求される場合
-
サーバーレスで API 層がない場合
- Supabase Edge Functions のみで構成
- ブラウザから直接 DB にアクセスする構成
参考資料
- PostgreSQL Row Level Security Documentation
- Supabase Row Level Security
- OWASP Top 10 - Broken Access Control
執筆: Founders Direct 開発者
更新日: 2025-12-02
適用プロジェクト: Founders Direct Cockpit (FDC) Phase 14.6