はじめに
「Supabaseって便利だよね、Firebase的に使えるし」
「フロントエンドから直接DBにアクセスできるの最高!」
...ちょっと待ってください。その設計、セキュリティ的に大丈夫ですか?
本記事では、実際のマルチテナント型AIチャットボットSaaSの開発経験をもとに、Supabase + SPAアーキテクチャでRLS(Row Level Security)がなぜ必須なのかを解説します。
TL;DR
- Supabase + SPAでフロントエンドから直接アクセスする場合、APIキーがブラウザに露出する
- RLSがないと、悪意あるユーザーが他社のデータを取得できる
- バックエンド経由(プロキシ)パターンなら従来通りの設計でもOK
- 直接アクセスパターンではRLSは必須のセキュリティ対策
アーキテクチャの3パターン
Supabase + SPAには、大きく3つのアーキテクチャパターンがあります。
パターン比較表
| パターン | APIキー露出 | RLS必須 | 主な用途 |
|---|---|---|---|
| 1. バックエンド経由 | ❌ 隠蔽 | 任意 | 従来型Webアプリ |
| 2. SPA直接アクセス | ⚠️ 露出 | 必須 | Supabaseフル活用 |
| 3. ハイブリッド | △ 一部露出 | 推奨 | 認証のみSupabase |
パターン別の詳細
パターン1: 従来型(バックエンド経由 / リバースプロキシ)
特徴:
-
service_roleキーはサーバー内に隠蔽 - アクセス制御はバックエンドで実装
- RLSは任意(多層防御として推奨)
- 従来のWebアプリと同じセキュリティモデル
適しているケース:
- 既存のバックエンドAPIがある
- 複雑なビジネスロジックが必要
- Supabaseをただのデータベースとして使用
パターン2: SPA直接アクセス(RLS必須)
特徴:
-
anonキーがブラウザで丸見え - アクセス制御はDB側(RLS)で実装が必須
- バックエンドレスで開発可能
- Supabaseの真価を発揮
適しているケース:
- 素早くMVPを作りたい
- バックエンドを持ちたくない
- Supabase Authをフル活用
パターン3: ハイブリッド
特徴:
- 認証はSupabase Auth(直接アクセス)
- データアクセスはバックエンド経由
- 両方のメリットを享受
- 本記事のチャットボットはこのパターン
補足:認証もバックエンド経由にするケース
パターン3では認証をSupabase Authに直接アクセスしていますが、認証もバックエンド経由(リバースプロキシ) にするケースがあります。
認証をバックエンド経由にする理由
| 理由 | 詳細 |
|---|---|
| 外部認証との統合 | SSO (SAML/OIDC)、社内Active Directory、他サービスのOAuth |
| セキュリティ要件 | 認証トークンをブラウザに一切露出させたくない |
| 追加ビジネスロジック | 認証時に監査ログ記録、利用規約同意チェック、カスタムMFA |
| 統一API設計 | 認証もデータも同じAPIエンドポイント経由 |
判断基準:
Supabase Authだけで完結する?
├─ Yes → 直接アクセス(パターン3)でOK
└─ No → バックエンド経由を検討
├─ 外部IdP (SSO/SAML) と統合したい
├─ 厳格なセキュリティポリシーがある
└─ 認証時に複雑な追加処理が必要
なぜパターン2でRLSが必須なのか
ブラウザの開発者ツールで見ると...
パターン2では、ブラウザの開発者ツールを開くとSupabaseの接続情報が見えます:
// ブラウザのコンソールで誰でも確認できる
const supabase = createClient(
'https://xxxxx.supabase.co',
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...' // anon key
)
悪意あるユーザーがこれを使って...
// 全企業のドキュメントを取得!
const { data } = await supabase
.from('documents')
.select('*')
console.log(data)
// [
// { id: 1, organization_id: 'A社', content: '機密情報...' },
// { id: 2, organization_id: 'B社', content: '競合他社のデータ...' },
// { id: 3, organization_id: 'C社', content: '顧客リスト...' },
// ]
これは重大なセキュリティインシデントです。
パターン1なら安全?
パターン1(バックエンド経由)なら、service_role キーはサーバー内に隠蔽されているため、上記の攻撃は成立しません。
ただし、RLSを設定しておくと多層防御になるため、推奨されます
バックエンドのバグ → RLSが最後の砦として機能
SQLインジェクション → RLSで被害を自組織に限定
RLS(Row Level Security)とは
RLSは、PostgreSQLの機能で、行レベルでアクセス制御を行う仕組みです。
RLSポリシーの例
-- 1. RLSを有効化
ALTER TABLE documents ENABLE ROW LEVEL SECURITY;
-- 2. ポリシーを作成:自組織のデータのみ閲覧可能
CREATE POLICY "Users can view own organization documents"
ON documents
FOR SELECT
USING (
organization_id = (
SELECT organization_id
FROM memberships
WHERE user_id = auth.uid()
AND status = 'active'
)
);
これにより、SQLで全件取得しようとしても、自動的に自組織のデータのみがフィルタリングされます。
実例:マルチテナント型チャットボットSaaS
システム概要
私たちが開発しているのは、企業向けのRAG(Retrieval-Augmented Generation)チャットボットSaaSです。
採用アーキテクチャ
私たちはパターン3(ハイブリッド) を採用しています
- 認証: Supabase Auth(フロントエンドから直接)
- データアクセス: FastAPI経由(service_role使用)
- RLS: 多層防御として有効化
実装パターン
パターン1:認証ユーザーのデータのみ(シンプル)
-- ユーザー自身のデータのみアクセス可能
CREATE POLICY "Users can access own data"
ON user_settings
FOR ALL
USING (user_id = auth.uid());
パターン2:組織ベースのアクセス制御(マルチテナント)
-- ヘルパー関数を作成(再帰を避けるため SECURITY DEFINER)
CREATE OR REPLACE FUNCTION app.user_has_active_membership(org_id uuid)
RETURNS boolean
LANGUAGE sql
SECURITY DEFINER
STABLE
AS $$
SELECT EXISTS (
SELECT 1 FROM memberships
WHERE user_id = auth.uid()
AND organization_id = org_id
AND status = 'active'
);
$$;
-- ポリシーで使用
CREATE POLICY "Users can view documents in their organization"
ON documents
FOR SELECT
USING (app.user_has_active_membership(organization_id));
パターン3:ロールベースのアクセス制御(RBAC)
-- 管理者のみ編集可能
CREATE POLICY "Admins can update documents"
ON documents
FOR UPDATE
USING (
EXISTS (
SELECT 1 FROM memberships
WHERE user_id = auth.uid()
AND organization_id = documents.organization_id
AND role IN ('admin', 'owner')
)
);
キーの使い分け
| キー | 露出 | RLS適用 | 用途 |
|---|---|---|---|
anon |
ブラウザに露出 | ✅ 適用 | 未認証ユーザー |
authenticated |
JWTに含まれる | ✅ 適用 | 認証済みユーザー |
service_role |
サーバー内のみ | ❌ バイパス | バックエンド処理 |
開発時のTips
1. RLSをバイパスしたい場合
開発やバッチ処理でRLSをバイパスしたい場合
# FastAPIバックエンドから
async with conn.transaction():
# セッション変数でバイパス
await conn.execute("SET LOCAL app.bypass_rls = 'true'")
# ここでは全データにアクセス可能
results = await conn.fetch("SELECT * FROM documents")
2. Supabase Dashboardでの確認
Supabase Dashboardはスーパーユーザー権限なので、RLSは自動的にバイパスされます。全データが見えるのは正常です。
3. RLSポリシーのデバッグ
-- 現在のユーザーIDを確認
SELECT auth.uid();
-- 現在のロールを確認
SELECT current_user, current_setting('role', true);
-- ポリシーが適用されているか確認
SELECT * FROM pg_policies WHERE tablename = 'documents';
よくある落とし穴
1. RLSを有効化しただけで安心
-- ❌ これだけではダメ!ポリシーがないと誰もアクセスできない
ALTER TABLE documents ENABLE ROW LEVEL SECURITY;
-- ✅ ポリシーも必要
CREATE POLICY "..." ON documents FOR SELECT USING (...);
2. FORCE ROW LEVEL SECURITY の使い忘れ
-- テーブル所有者もRLSを適用したい場合
ALTER TABLE documents FORCE ROW LEVEL SECURITY;
3. サービスロールキーをフロントエンドで使用
// ❌ 絶対にやってはいけない!
const supabase = createClient(url, 'service_role_key_ここに書いちゃダメ')
4. パターン1なのにRLSに頼りすぎ
バックエンド経由なのに、バックエンドでアクセス制御を実装せず
RLSだけに頼る → service_roleはRLSバイパスするので意味がない!
アーキテクチャ選択のフローチャート
チェックリスト
プロダクションにデプロイする前に確認
共通
-
service_roleキーがフロントエンドに露出していないか - 環境変数でキーを管理しているか
パターン2(直接アクセス)の場合
- 全テーブルでRLSが有効化されているか
- 適切なポリシーが設定されているか
- Supabase Security Advisorで警告がないか
- 開発者ツールで他組織のデータが見えないか
パターン1/3(バックエンド経由)の場合
- バックエンドで適切な認可チェックを実装しているか
- RLSを多層防御として設定しているか(推奨)
まとめ
Supabase + SPAは開発効率が高い一方で、アーキテクチャパターンによってセキュリティ設計が大きく異なります。
- パターン2(直接アクセス): RLSは必須
- パターン1/3(バックエンド経由): RLSは多層防御として推奨
自分のプロジェクトがどのパターンかを把握し、適切なセキュリティ対策を実装しましょう。