過去2年間で、私はSupabaseで3つのマルチテナントSaaS アプリケーションをリリースしました。1番目のものは災難でした。Supabaseが失敗したわけではなく、行レベルセキュリティ(RLS)がスケール時にクエリプランにどのように相互作用するかを根本的に誤解していたからです。2番目はより良くなりました。3番目のものは2,000以上のテナントを処理し、数百万行のテーブル全体で50ms未満のクエリ時間を実現しています。

この記事は、最初のプロジェクトの前に誰かが私に伝えてくれたはずのすべてです。実際のマルチテナントスキーマをゼロから構築し、実際に機能するRLSポリシーを配線し、実際のトラフィックがデータベースにヒットしたときにのみ現れるエッジケースをカバーします。

目次

Supabase RLS Multi-Tenant Production Schema Design Guide

Supabaseのマルチテナント方式

1行のSQLを書く前に、マルチテナントの3つの方式を明確にし、なぜそのうちの1つがほとんどのSupabaseプロジェクトで勝つのかを理解しましょう。

方式 分離レベル 複雑性 テナントあたりのコスト 最適な用途
テナントごとのデータベース 最高 非常に高い $25+/月 テナントあたり エンタープライズ、コンプライアンス重視
テナントごとのスキーマ 高い 高い $5-15/月 テナントあたり ミッドマーケット SaaS
共有スキーマ + RLS 中程度 中程度 テナントあたりわずか ほとんどの SaaS アプリ

テナントごとのデータベースは、銀行やヘルスケア企業など、文字通り個別のインフラストラクチャを必要とする企業に販売する場合に使用するものです。Supabaseではこれを簡単にしません。複数のSupabaseプロジェクトを管理する必要があります。

テナントごとのスキーマ(tenant_123.projectsのようなPostgreSQLスキーマを使用)は魅力的に見えますが、メンテナンスの悪夢に変わります。すべてのマイグレーションはすべてのスキーマに対して実行されます。私はこれを一度試しました。400テナントがあると、単純なALTER TABLEマイグレーションに45分かかりました。

RLSを使用した共有スキーマは、90%の SaaS アプリケーションにとって最適な選択肢です。1つのテーブルセット、1つのマイグレーションセット、そしてPostgreSQLのRLSポリシーが分離を処理します。これが私たちがここで構築しているものです。

基礎:テナントとユーザースキーマ

コアテーブルから始めましょう。チュートリアルのおもちゃではなく、本番環境で使用しているスキーマを示します。

-- UUID生成を有効にする
create extension if not exists "uuid-ossp";

-- テナント(組織、ワークスペース、何と呼ぶかはあなた次第)
create table public.tenants (
  id uuid primary key default uuid_generate_v4(),
  name text not null,
  slug text unique not null,
  plan text not null default 'free' check (plan in ('free', 'pro', 'enterprise')),
  settings jsonb not null default '{}',
  created_at timestamptz not null default now(),
  updated_at timestamptz not null default now()
);

-- メンバーシップ結合テーブル:auth.usersをテナントに接続
create table public.tenant_memberships (
  id uuid primary key default uuid_generate_v4(),
  tenant_id uuid not null references public.tenants(id) on delete cascade,
  user_id uuid not null references auth.users(id) on delete cascade,
  role text not null default 'member' check (role in ('owner', 'admin', 'member', 'viewer')),
  created_at timestamptz not null default now(),
  
  unique(tenant_id, user_id)
);

-- 重要:RLSポリシーが活用するインデックス
create index idx_tenant_memberships_user_id on public.tenant_memberships(user_id);
create index idx_tenant_memberships_tenant_id on public.tenant_memberships(tenant_id);
create index idx_tenant_memberships_user_tenant on public.tenant_memberships(user_id, tenant_id);

注意すべき2つのことがあります。まず、ユーザープロフィールに直接tenant_idを置く代わりに、結合テーブル(tenant_memberships)を使用しています。ユーザーは複数のテナントに属することができます。これはほぼすべてのSaaS アプリケーションの実際の要件です。2番目に、これらのインデックスはオプションではありません。それらがないと、すべてのRLSチェックはメンバーシップテーブル上でシーケンシャルスキャンを実行します。数千のメンバーシップを持つと、クエリに200ms以上追加されるのを見ました。

パフォーマンスを損なわないRLSポリシーの設計

ここがほとんどのチュートリアルがあなたを失敗させる場所です。彼らはあなたに次のような単純なポリシーを示します:

-- 本番環境ではこれを行わないでください
create policy "ユーザーはテナントのデータを表示できます"
  on public.projects for select
  using (
    tenant_id in (
      select tenant_id from public.tenant_memberships
      where user_id = auth.uid()
    )
  );

これは機能します。テストに合格します。そして、500人のユーザーを取得してデプロイすると、ダッシュボードに4秒かかることに疑問を持ちます。

問題は、PostgreSQLがすべての単一行に対してこのサブクエリを評価することです。クエリプランナーは時々それを最適化できます、しかしそれはしばしばしません。特にJOINが関係している場合。

セキュリティディファイナー関数パターン

本番環境で実際に機能するものは次のとおりです:

-- ユーザーのテナント ID を返す関数を作成します
-- SECURITY DEFINER は関数作成者の権限で実行されることを意味します
-- これは重要です:メンバーシップテーブル自体の RLS をバイパスします
create or replace function public.get_user_tenant_ids()
returns setof uuid
language sql
security definer
stable
set search_path = public
as $$
  select tenant_id 
  from public.tenant_memberships 
  where user_id = auth.uid();
$$;

-- ポリシーで使用します
create policy "tenant_isolation_select"
  on public.projects for select
  using (tenant_id in (select public.get_user_tenant_ids()));

create policy "tenant_isolation_insert"
  on public.projects for insert
  with check (tenant_id in (select public.get_user_tenant_ids()));

create policy "tenant_isolation_update"
  on public.projects for update
  using (tenant_id in (select public.get_user_tenant_ids()))
  with check (tenant_id in (select public.get_user_tenant_ids()));

create policy "tenant_isolation_delete"
  on public.projects for delete
  using (tenant_id in (select public.get_user_tenant_ids()));

なぜこれが速いのか?STABLEキーワードは、PostgreSQLに関数が単一のステートメント内で同じ結果を返すことを指示します。プランナーはそれを一度呼び出して結果を再利用できます。私のベンチマークでは、複数の行に触れるクエリでRLSオーバーヘッドが60~80%削減されます。

search_path トラップ

その関数のset search_path = publicを見ましたか?これはオプションではありません。これがないと、悪意のあるユーザーは別のスキーマでauth.uid()をシャドウしてセキュリティをバイパスする可能性のある関数を作成できます。Supabaseチームはこれについて書いていますが、見逃すのは簡単です。

Supabase RLS Multi-Tenant Production Schema Design Guide - architecture

tenant_idパターン:正しく実装する

テナント固有のデータを保持するすべてのテーブルにtenant_id列が必要です。例外なし。たとえテーブルが別のテナントスコープのテーブルに外部キーを持っている場合でも。理由は次のとおりです:

-- プロジェクトテーブル
create table public.projects (
  id uuid primary key default uuid_generate_v4(),
  tenant_id uuid not null references public.tenants(id) on delete cascade,
  name text not null,
  created_at timestamptz not null default now()
);

-- タスクはプロジェクトに属しています。tenant_idはここで冗長だと思うかもしれません。
create table public.tasks (
  id uuid primary key default uuid_generate_v4(),
  tenant_id uuid not null references public.tenants(id) on delete cascade,
  project_id uuid not null references public.projects(id) on delete cascade,
  title text not null,
  status text not null default 'todo',
  created_at timestamptz not null default now()
);

-- RLS と一般的なクエリパターン用のインデックス
create index idx_projects_tenant on public.projects(tenant_id);
create index idx_tasks_tenant on public.tasks(tenant_id);
create index idx_tasks_project on public.tasks(project_id);
create index idx_tasks_tenant_project on public.tasks(tenant_id, project_id);

はい、taskstenant_idは非正規化されています。はい、それは正しい呼び出しです。それなしに、tasksのRLSポリシーはprojectsにJOINしてテナントを検証する必要があります。そしてそのJOINはすべてのクエリで発生します。非正規化されたtenant_idを使用すれば、RLSチェックは単純なインデックスルックアップです。

トリガーで一貫性を実施します:

create or replace function public.verify_tenant_consistency()
returns trigger
language plpgsql
as $$
begin
  if NEW.tenant_id != (
    select tenant_id from public.projects where id = NEW.project_id
  ) then
    raise exception 'tenant_id が一致しません:タスクのテナントがプロジェクトのテナントと一致しません';
  end if;
  return NEW;
end;
$$;

create trigger check_task_tenant
  before insert or update on public.tasks
  for each row execute function public.verify_tenant_consistency();

JWTクレームとデータベースルックアップ

Supabaseを使用すると、カスタムクレームをJWTトークンに埋め込むことができます。一部の人々はこれを使用して現在のテナント ID を保存します:

-- JWT から読み込む(高速ですが注意点があります)
auth.jwt() -> 'app_metadata' ->> 'current_tenant_id'

毎回データベースから検索するのと比較して:

-- データベースルックアップ(遅い、ただし常に最新)
select tenant_id from tenant_memberships where user_id = auth.uid()
側面 JWT クレーム データベース ルックアップ
速度 ~0.1ms ~1-5ms
鮮度 トークン更新まで古い 常に現在
マルチテナント切り替え トークン更新が必要 即座
失効時のセキュリティ JWT の有効期限まで遅延 即座
実装の複雑さ より高い(Edge Function が必要) より低い

私の推奨事項:ほとんどのアプリではセキュリティディファイナー関数パターンを使用したデータベースルックアップを使用します。適切なインデックスがあればパフォーマンスの違いは無視できるし、古いトークンに関するバグの整クラスを避けることができます。

10,000以上の同時ユーザーにサービスを提供しており、ミリ秒を削っているのであれば、アクティブなテナント ID をJWTに移動します。ユーザーがテナントを切り替えるときに要求を設定するにはSupabase Edge Function(またはフック)が必要になり、クライアント上でトークン更新フローを処理する必要があります。

クロステナント操作の処理

一部の操作は正当にテナント境界を越える必要があります。管理ダッシュボード、請求システム、分析集計。それらを安全に処理する方法は次のとおりです。

サービスロールキー(慎重に使用)

Supabaseサービスロールキーは RLS を完全にバイパスします。サーバー側コードでのみ使用します。クライアントに公開しないでください。

// サーバー側のみ(Next.js API ルート、Edge Function など)
import { createClient } from '@supabase/supabase-js';

const supabaseAdmin = createClient(
  process.env.NEXT_PUBLIC_SUPABASE_URL!,
  process.env.SUPABASE_SERVICE_ROLE_KEY! // これを決して公開しないでください
);

// これは RLS をバイパスします - 非常に注意して使用してください
const { data } = await supabaseAdmin
  .from('tenants')
  .select('id, name, plan')
  .eq('plan', 'enterprise');

Next.js で構築している場合(私たちはこれの多くを行います)、あなたの API ルートとサーバーコンポーネントはサービスロール操作の正しい場所です。

制御されたクロステナントアクセスのためのデータベース関数

より細かい制御のために、特定の関数を作成します:

create or replace function public.admin_get_tenant_stats(target_tenant_id uuid)
returns json
language plpgsql
security definer
set search_path = public
as $$
declare
  result json;
  caller_role text;
begin
  -- 呼び出し元がターゲットテナントの管理者であることを確認します
  select role into caller_role
  from tenant_memberships
  where user_id = auth.uid() and tenant_id = target_tenant_id;
  
  if caller_role not in ('owner', 'admin') then
    raise exception '権限がありません:管理者ロールが必要です';
  end if;
  
  select json_build_object(
    'project_count', (select count(*) from projects where tenant_id = target_tenant_id),
    'task_count', (select count(*) from tasks where tenant_id = target_tenant_id),
    'member_count', (select count(*) from tenant_memberships where tenant_id = target_tenant_id)
  ) into result;
  
  return result;
end;
$$;

マイグレーション戦略とスキーマ進化

既存のテーブルにtenant_idを追加することは、誰もが恐れているマイグレーションです。ダウンタイムを最小化するアプローチは次のとおりです:

-- ステップ 1:null 許容列を追加します
alter table public.some_existing_table 
  add column tenant_id uuid references public.tenants(id);

-- ステップ 2:バックフィル(大きなテーブルについてはバッチで行う)
update public.some_existing_table set tenant_id = (
  select tenant_id from public.projects 
  where projects.id = some_existing_table.project_id
)
where tenant_id is null;

-- ステップ 3:NOT NULL 制約を追加します
alter table public.some_existing_table 
  alter column tenant_id set not null;

-- ステップ 4:インデックスを追加します
create index concurrently idx_some_table_tenant 
  on public.some_existing_table(tenant_id);

-- ステップ 5:RLS を有効にしてポリシーを追加します
alter table public.some_existing_table enable row level security;

create policy "tenant_isolation" on public.some_existing_table
  for all using (tenant_id in (select public.get_user_tenant_ids()));

インデックス作成時のconcurrentlyキーワードは重要です。これがないと、インデックス構築全体でテーブルがロックされます。100万行のテーブルでは、それは数分のダウンタイムになります。

パフォーマンスベンチマークと最適化

tasksテーブルに500,000行が1,000テナントに分散されたSupabase Pro プラン(2025年初頭の時点で月額$25)でベンチマークを実行しました。

クエリパターン RLS なし Naive RLS 最適化 RLS オーバーヘッド
50行を選択(単一テナント) 2.1ms 18.4ms 3.8ms +81%
単一行を挿入 1.2ms 4.1ms 1.9ms +58%
フィルター付きカウント 3.4ms 22.1ms 5.2ms +53%
2つのテーブル間で結合 4.8ms 45.2ms 8.1ms +69%

「最適化された RLS」は以下を意味します:セキュリティディファイナー関数、適切な複合インデックス、子テーブルに非正規化されたtenant_id、およびSTABLE関数の揮発性。

Naive アプローチ(インラインサブクエリ、欠落したインデックス)は RLS が遅く感じさせます。最適化すれば、オーバーヘッドは本番ワークロードにとって完全に受け入れられます。

追加の最適化のヒント

  1. 複合インデックスを使用 tenant_id をリーディング列として:create index on tasks(tenant_id, status, created_at)
  2. 大規模テーブルをパーティション化 tenant_id によって、単一のテナントが数百万行を持つ場合
  3. pg_stat_statements を使用 遅いクエリを検出するために。Supabase はダッシュボードの Database > Query Performance でこれを公開します
  4. マテリアライズドビューを検討 クロステナント分析の場合、リアルタイムで高コストの集約を実行する代わりに

本番環境向けセキュリティ強化

RLS がプライマリ防御ですが、唯一のものであってはいけません。

1. デフォルト拒否

常に RLS を有効にし、デフォルトポリシーなし。つまり、新しいテーブルにポリシーを追加するのを忘れた場合、広く開いているのではなくロックダウンされています。

alter table public.new_table enable row level security;
-- アクセスパターンを考え抜くまで、許可ポリシーを追加しないでください

2. 監査ログ

create table public.audit_log (
  id bigint generated always as identity primary key,
  tenant_id uuid not null,
  user_id uuid,
  action text not null,
  table_name text not null,
  record_id uuid,
  old_data jsonb,
  new_data jsonb,
  created_at timestamptz not null default now()
);

-- 汎用監査トリガー
create or replace function public.audit_trigger_func()
returns trigger
language plpgsql
security definer
set search_path = public
as $$
begin
  if TG_OP = 'DELETE' then
    insert into audit_log(tenant_id, user_id, action, table_name, record_id, old_data)
    values (OLD.tenant_id, auth.uid(), TG_OP, TG_TABLE_NAME, OLD.id, to_jsonb(OLD));
    return OLD;
  else
    insert into audit_log(tenant_id, user_id, action, table_name, record_id, new_data, old_data)
    values (
      NEW.tenant_id, auth.uid(), TG_OP, TG_TABLE_NAME, NEW.id, 
      to_jsonb(NEW),
      case when TG_OP = 'UPDATE' then to_jsonb(OLD) else null end
    );
    return NEW;
  end if;
end;
$$;

3. エッジでのレート制限

RLS はデータ漏洩を防ぎますが、虐待を防ぎません。Supabase Edge Functions または Next.js ミドルウェアをレート制限に使用します。Astro で構築している場合、サーバーエンドポイントで処理できます。

4. ポリシーをテストします

実際のテストを書いてください。Supabase のローカル開発環境(supabase start)を使用し、以下をテストしてください:

  • ユーザー A はユーザー B のテナントデータを見ることができません
  • メンバーシップを削除するとアクセスが直ちに取り消されます
  • サービスロールが RLS を正しくバイパスします
  • Insert/update ポリシーが tenant_id の改ざんを防ぎます
// Vitest を使用したテスト例
import { describe, it, expect } from 'vitest';
import { createClient } from '@supabase/supabase-js';

describe('RLS Policies', () => {
  it('should prevent cross-tenant data access', async () => {
    const userA = createClient(SUPABASE_URL, SUPABASE_ANON_KEY, {
      global: { headers: { Authorization: `Bearer ${tokenA}` } }
    });
    
    const { data, error } = await userA
      .from('projects')
      .select('*')
      .eq('tenant_id', TENANT_B_ID);
    
    expect(data).toHaveLength(0); // RLS はこれらをフィルター処理する必要があります
  });
});

実際のスキーマ例

プロジェクト管理 SaaS の要約されたが完全なスキーマです。これは私が本番環境にデプロイしたものに近いものです。

-- すべてのテーブルで RLS を有効にします
alter table public.tenants enable row level security;
alter table public.tenant_memberships enable row level security;
alter table public.projects enable row level security;
alter table public.tasks enable row level security;

-- テナント:ユーザーは属しているテナントを見ることができます
create policy "view_own_tenants" on public.tenants for select
  using (id in (select public.get_user_tenant_ids()));

-- 所有者のみがテナント設定を更新できます
create policy "owners_update_tenants" on public.tenants for update
  using (
    id in (
      select tenant_id from public.tenant_memberships
      where user_id = auth.uid() and role = 'owner'
    )
  );

-- メンバーシップ:メンバーはテナント内の他のメンバーを見ることができます
create policy "view_tenant_members" on public.tenant_memberships for select
  using (tenant_id in (select public.get_user_tenant_ids()));

-- 管理者以上のみがメンバーシップを管理できます
create policy "admins_manage_members" on public.tenant_memberships for insert
  with check (
    tenant_id in (
      select tenant_id from public.tenant_memberships
      where user_id = auth.uid() and role in ('owner', 'admin')
    )
  );

-- プロジェクト&タスク:標準的なテナント分離
create policy "tenant_projects" on public.projects for all
  using (tenant_id in (select public.get_user_tenant_ids()))
  with check (tenant_id in (select public.get_user_tenant_ids()));

create policy "tenant_tasks" on public.tasks for all
  using (tenant_id in (select public.get_user_tenant_ids()))
  with check (tenant_id in (select public.get_user_tenant_ids()));

このようなものを構築しており、前に実行したチームが必要な場合は、チェックしてください。

FAQ

Supabase RLS はクエリに大きなレイテンシを追加しますか?

最適化されたポリシー(セキュリティディファイナー関数、適切なインデックス、非正規化されたtenant_id)を使用すれば、オーバーヘッドは通常、生のクエリ時間の50~80%です。RLS なしで3ms かかるクエリの場合、5~6ms と予想してください。これは本番用途にとって完全に受け入れられます。Naive アプローチは RLS に10~20倍のオーバーヘッドを追加する可能性があります。これが最適化が重要な理由です。

Supabase Realtime サブスクリプションで RLS を使用できますか?

はい。Supabase Realtime は RLS ポリシーを尊重します。クライアントがテーブルの変更にサブスクライブするとき、彼らは見る権限がある行の取得イベントのみを受け取ります。これは自動的に機能します。クライアント上で追加のフィルタリング ロジックを追加する必要はありません。RLS ポリシーが効率的であることを確認してください。すべてのブロードキャストに対して評価されるため。

UI でテナント切り替えを処理するにはどうすればよいですか?

アクティブなテナント ID をアプリのステート(React context、Zustand store など)に保存し、クエリでtenant_idフィルターとして渡します。RLS はすでに結果をユーザーが属するテナントに制限しているため、切り替えはフィルターするtenant_idを変更するだけの問題です。データベースルックアップアプローチを使用している場合、トークン更新は不要です。

Supabase の組み込み認証を使用するべきですか、それとも Clerk などの外部プロバイダを使用するべきですか?

Supabase Auth はauth.uid()を通じて RLS と自然に統合されます。外部プロバイダを使用する場合は、ユーザーを Supabase に同期し、カスタム JWT クレームまたはマッピング テーブルを使用する必要があります。具体的な理由がない限り、Supabase Auth に固執してください。かなりの統合作業を節約できます。

1つの Supabase プロジェクトはいくつのテナントを処理できますか?

共有スキーママルチテナントで、Supabase Pro プラン(月額$25)で2,000以上のテナントを個人的に実行しました。実用的な制限はテナント数ではなく、総データ量とクエリパターンに依存します。Supabase Pro インスタンス(2 vCPU、1GB RAM)は、インデックスが正しければ、数千万行のテーブルを処理できます。

新しいテーブルで RLS を有効にするのを忘れた場合はどうなりますか?

Supabase クライアントを anon キーで使用する場合、RLS が有効になっていないテーブルはそのキーを持っている人がアクセスできます。これは Supabase の最大の落とし穴です。publicスキーマのすべてのテーブルが RLS を有効にしていることを確認する CI チェックを設定します。pg_tablespg_classと結合してこのチェックを自動化できます。

Supabase Edge Functions で RLS を使用できますか?

はい。Edge Functions は、anon キー(RLS が適用される)またはサービスロールキー(RLS がバイパスされる)を使用して Supabase クライアントを作成できます。ユーザーファイング操作には anon キーをユーザーの JWT で使用し、クロステナントアクセスが必要な管理タスクにはサービスロールキーを使用します。

シングルテナント アプリからマルチテナントに移行するにはどうすればよいですか?

tenant_idを null 許容列として追加し、すべての既存データをバックフィルします(シングルテナントであったため、すべての行が同じテナント ID を取得します)。次に、NOT NULL 制約、インデックス、および RLS ポリシーを追加します。既存データで徹底的にテストしてから RLS を有効にしてください。間違ったポリシーはすべてのユーザーをロックアウトできます。Supabase のローカル開発環境を使用してマイグレーションをリハーサルしてください。