言語を切り替える
テーマを切り替える

Supabase Auth 高度な設定:OAuth、SSO、権限制御の完全ガイド

ある日の午後、営業担当の同僚が駆け込んできました。「クライアントが Okta でのログインを要求していて、2週間以内に SSO をリリースしないといけない」。一瞬呆然としました。私のアプリはメール登録と Google OAuth しか対応していません。SAML? 触れたこともありません。

正直なところ、このシナリオは B2B SaaS では珍しくありません。企業クライアントは従業員に個別のアカウント登録をさせません。彼らは統一されたアイデンティティ管理システム(Okta、Azure AD、Google Workspace)を持っており、ワンクリックでログインでき、従業員が退職すれば自動的にアカウントが無効になります。

この記事は、まさにそのようなシナリオのために書かれました。OAuth ソーシャルログインから始め、SAML SSO 企業統合へと進み、最後に Row Level Security(RLS)でマルチテナント権限分離を実現します。コンシューマーレベルから企業レベルまで対応する、完全な認証・認可ソリューションです。

一、OAuth マルチプロバイダー設定の実践

OAuth ソーシャルログインは、ほとんどのアプリの出発点です。ユーザーはパスワードを覚えたくありませんし、開発者もパスワードの保存と検証を扱いたくありません。Google や GitHub に任せれば、双方にとって楽になります。

Supabase がサポートする OAuth プロバイダーは多数あります:Google、GitHub、Apple、Facebook、Discord、Twitter… しかし実際の本番環境では、Google と GitHub が最も広く使われており、Apple は iOS アプリの必須要件です(App Store 審査で必要)。まずは Google から始めましょう。

Google OAuth 設定

Google Cloud Console を開くと、メニューが複雑で少し戸惑うかもしれません。慌てずに、「OAuth Client ID」で検索すれば入り口が見つかります。

作成時に Web application タイプを選択し、重要なステップは Authorized redirect URI を入力することです:

https://<あなたのプロジェクトref>.supabase.co/auth/v1/callback

この URL は、Supabase Auth サービスが OAuth コールバックを受け取る場所です。プロジェクト ref は Supabase Dashboard の左上で確認でき、abcdefghijklmnop のような形式です。

Client ID と Client Secret を取得したら、Supabase Dashboard に戻り、Authentication > Providers で Google を有効にし、これら2つの値を入力します。

コードからの呼び出しは簡単です:

import { createClient } from '@supabase/supabase-js'

const supabase = createClient(
  'https://abcdefghijklmnop.supabase.co',
  'your-anon-key'
)

// OAuth ログインを開始
const { data, error } = await supabase.auth.signInWithOAuth({
  provider: 'google',
  options: {
    redirectTo: 'https://your-app.com/auth/callback',
    scopes: 'email profile'
  }
})

if (error) {
  console.error('ログイン失敗:', error.message)
  return
}

// data.url がリダイレクト先、window.location.href = data.url で遷移

redirectTo は、アプリがログイン結果を受け取る URL です。Supabase は code と state パラメータを送信します。そのページで supabase.auth.exchangeCodeForSession() を呼び出してログインを完了させます:

// /auth/callback ページで
const { error } = await supabase.auth.exchangeCodeForSession()
if (!error) {
  // ログイン成功、ホームにリダイレクト
  window.location.href = '/'
}

GitHub OAuth 設定

GitHub の設定も同様です。Settings > Developer settings > OAuth Apps が入り口です。作成時に入力する Authorization callback URL は、同じく https://<プロジェクトref>.supabase.co/auth/v1/callback です。

1つ違いがあります:GitHub OAuth App にはスコープ設定画面がありません。スコープはコード内で指定します:

await supabase.auth.signInWithOAuth({
  provider: 'github',
  options: {
    redirectTo: 'https://your-app.com/auth/callback',
    scopes: 'repo user'  // repo でプライベートリポジトリにアクセス可能
  }
})

ユーザーログインだけなら、デフォルトのスコープで十分です。ユーザーの GitHub データにアクセスする必要がある場合(リポジトリ一覧の同期など)は、repo を追加します。

Apple OAuth 設定

Apple の設定が最も複雑です。Apple Developer Portal で Services ID を作成し、秘密鍵(.p8 ファイル)も生成する必要があります。秘密鍵は1回しかダウンロードできず、紛失した場合は再生成が必要です。

主要なパラメータ:

  • Services ID:Client ID に相当
  • Team ID:Membership ページで確認
  • Key ID:秘密鍵の ID
  • Private Key:ダウンロードした .p8 ファイルの内容

Supabase Dashboard でこれらの値を入力する際、Private Key はファイルの内容全体(BEGIN/END の行を含む)をコピーします。

呼び出しコードは Google/GitHub と同じです:

await supabase.auth.signInWithOAuth({
  provider: 'apple',
  options: {
    redirectTo: 'https://your-app.com/auth/callback'
  }
})

iOS アプリには別の方法があります:ネイティブの Sign in with Apple API を直接呼び出し、identity token を取得して Supabase に渡します:

// フロントエンドで Apple から返された identity_token を受け取る
const { data, error } = await supabase.auth.signInWithIdTokenCredentials({
  provider: 'apple',
  token: identityToken
})

この方法は、すでにネイティブの Apple ログインを統合している iOS アプリに適しており、既存のロジックを再利用できます。

二、SAML SSO 企業統合

冒頭のシナリオに戻りましょう:クライアントが Okta でのログインを要求しています。ここで OAuth では不十分で、SAML 2.0 SSO が必要になります。

SAML の動作方式は OAuth と全く異なります。OAuth はユーザーがサードパーティアプリに自分のデータへのアクセスを許可するものですが、SAML は企業のアイデンティティプロバイダー(IdP)がアプリケーション(ServiceProvider, SP)にユーザー識別情報を送信するものです。企業にとって、SAML の方が管理しやすいです。ユーザーが退職すれば IdP でアカウントを無効化するだけで、接続されているすべてのアプリケーションで自動的に無効になります。

設定前の準備

クライアントから IdP の Metadata を入手する必要があります。このファイルには IdP の証明書、エンドポイントアドレスなどの情報が含まれています。Okta、Azure AD、Google Workspace にはすべて Metadata をエクスポートする機能があります。

Supabase が提供する重要な URL:

EntityID(SP 識別子):
https://<プロジェクトref>.supabase.co/auth/v1/sso/saml/metadata

ACS URL(SAML Response を受け取るアドレス):
https://<プロジェクトref>.supabase.co/auth/v1/sso/saml/acs

Metadata URL(ダウンロード可能):
https://<プロジェクトref>.supabase.co/auth/v1/sso/saml/metadata?download=true

これらの URL をクライアントの IT 管理者に伝え、Okta/Azure AD で SAML アプリケーションを作成してもらいます。

Supabase CLI で SSO を設定

Supabase Dashboard も SAML 設定をサポートしていますが、私は CLI を使う方が好みです。企業クライアントの設定は何度もデバッグが必要になることがあり、コマンドラインの方が制御しやすいからです。

# SAML 接続を追加
supabase sso add --type saml --project-ref <プロジェクトref> \
  --metadata-url 'https://company.okta.com/app/exk123/saml/samlmetadata' \
  --domains company.com

--domains パラメータが重要です。これは Supabase に、「すべての company.com ドメインのユーザーがログインする場合、この SAML 接続を使用する」ことを伝えます。

--metadata-file を使って --metadata-url の代わりに XML ファイルを直接アップロードすることもできます:

supabase sso add --type saml --project-ref <プロジェクトref> \
  --metadata-file ./okta-metadata.xml \
  --domains company.com

コマンドを実行すると、abc123def456 のような sso_provider_id が返されます。この ID は RLS 権限分離の鍵となります。

Attribute Mapping の設定

SAML Response にはユーザー情報(メール、氏名、部署など)が含まれていますが、フィールド名は統一されていない可能性があります。Supabase にこれらのフィールドをどうマッピングするかを伝える必要があります。

JSON ファイルを作成します:

{
  "keys": {
    "email": {
      "name": "email",
      "names": ["EmailAddress", "email", "mail"],
      "required": true
    },
    "first_name": {
      "name": "first_name",
      "names": ["FirstName", "givenName", "first_name"]
    },
    "last_name": {
      "name": "last_name",
      "names": ["LastName", "surname", "last_name"]
    }
  }
}

そして supabase sso update でこの設定を適用します:

supabase sso update <sso_provider_id> \
  --project-ref <プロジェクトref> \
  --attribute-mapping-file ./mapping.json

マルチテナント SSO 設定

1つのプロジェクトに複数の SAML 接続を追加できます。例えば、2つの企業クライアントがいるとします。1つは Acme Corp(Okta 使用)、もう1つは Globex Inc(Azure AD 使用):

# Acme Corp の SSO を追加
supabase sso add --type saml --project-ref <プロジェクトref> \
  --metadata-url 'https://acme.okta.com/.../metadata' \
  --domains acme.com

# sso_provider_id: provider_abc が返される

# Globex Inc の SSO を追加
supabase sso add --type saml --project-ref <プロジェクトref> \
  --metadata-url 'https://globex.azure.com/.../metadata' \
  --domains globex.com

# sso_provider_id: provider_def が返される

これで、Acme Corp の従業員がログインすると provider_abc、Globex Inc の従業員は provider_def を経由します。各 SSO 接続のユーザーデータは分離されています。

ユーザーログイン体験

設定完了後、ユーザーのログインフローは次のようになります:

  1. ユーザーがメールアドレスを入力(例:[email protected]
  2. Supabase が acme.com ドメインに SSO 設定があることを検出
  3. Acme の Okta ログインページに自動的にリダイレクト
  4. ユーザーが Okta でアカウントとパスワードを入力(すでにログイン済みの場合はそのまま通過)
  5. Okta が SAML Response を Supabase に送信
  6. Supabase が検証後セッションを作成し、アプリケーションにリダイレクト

コード側で SSO ログインを開始:

// ユーザーがメールを入力した後、このメソッドを呼び出す
const { data, error } = await supabase.auth.signInWithSSO({
  domain: 'acme.com'
})

if (data?.url) {
  // SSO ログインページにリダイレクト
  window.location.href = data.url
}

sso_provider_id を直接使うこともできます:

const { data, error } = await supabase.auth.signInWithSSO({
  providerId: 'provider_abc'
})

SSO ユーザーの特徴

SSO ログインで作成されるユーザーは通常ユーザーとは異なります:

  • メールは IdP が管理:ユーザーはアプリ内でメールを変更できません
  • パスワードなし:パスワードは IdP に保存され、Supabase は検証に関与しません
  • メールは未検証:IdP がすでに検証済みだからです

これは、ユーザーがメールを変更する必要がある場合、クライアントの IT 管理者に Okta/Azure AD で操作を依頼する必要があることを意味します。

三、Row Level Security の高度な使い方

認証は「ユーザーが誰か」の問題を解決し、認可は「ユーザーが何ができるか」の問題を解決します。

従来のアプローチは、ビジネスコードで権限をチェックすることです。各 API で現在のユーザーがそのデータにアクセスできるかを判断します。コードが重複し、見落としが発生しやすく、パフォーマンスも良くありません。各リクエストでデータベースをクエリして権限を判断する必要があるからです。

PostgreSQL の Row Level Security(RLS)は、権限チェックをデータベース層に移します。各クエリに自動的に権限フィルタが適用され、ビジネスコードが気にする必要がありません。

RLS 有効化後のデフォルト動作

多くの人がここでハマります:RLS を有効にすると、デフォルトですべてのアクセスが拒否されます

-- RLS を有効化
ALTER TABLE posts ENABLE ROW LEVEL SECURITY;

-- この時点で、すべてのクエリが空を返す(管理者も含む)
SELECT * FROM posts;  -- 0 行を返す

アクセスを許可するには Policy を作成する必要があります。

Policy の2つの重要な部分

Policy には2つの句があります:USING と WITH CHECK。

USING 句:ユーザーがそのデータを「見る」または「特定」できるかを判断します。SELECT、UPDATE、DELETE 操作に使用されます。ユーザーはまずデータを見ることができてから、変更または削除できます。

WITH CHECK 句:ユーザーがそのデータを「書き込める」かを判断します。INSERT、UPDATE 操作に使用されます。書き込まれた後のデータが条件を満たす必要があります。

-- ユーザーは自分の投稿のみ操作可能
CREATE POLICY "Users manage own posts" ON posts
FOR ALL TO authenticated
USING (auth.uid() = author_id)
WITH CHECK (auth.uid() = author_id);

-- 閲覧のみ許可(変更不可)
CREATE POLICY "Users view own posts" ON posts
FOR SELECT TO authenticated
USING (auth.uid() = author_id);

-- 挿入のみ許可(他人のものは閲覧不可)
CREATE POLICY "Users insert posts" ON posts
FOR INSERT TO authenticated
WITH CHECK (auth.uid() = author_id);

auth.uid() は現在ログインしているユーザーの UUID を返します。未ログインの場合は null を返すため、TO authenticated は Policy がログインユーザーにのみ適用されることを保証します。

RESTRICTIVE vs PERMISSIVE

1つのテーブルに複数の Policy を作成できます。デフォルトは PERMISSIVE モードで、いずれかの Policy を満たせばアクセスできます。

-- Policy 1:ユーザーは自分のものを見れる
CREATE POLICY "Own data" ON posts
FOR SELECT USING (auth.uid() = author_id);

-- Policy 2:公開投稿は誰でも見れる
CREATE POLICY "Public posts" ON posts
FOR SELECT USING (is_public = true);

-- 2つの PERMISSIVE Policy:いずれかを満たせばOK

RESTRICTIVE Policy は「必ず満たす必要がある」ものです。PERMISSIVE Policy と組み合わせて、より厳格な制限を形成します。

-- すべてのアクセスはこの条件を満たす必要がある(「グローバルフィルタ」として)
CREATE POLICY "Tenant isolation" ON posts
AS RESTRICTIVE TO authenticated
USING (tenant_id = (
  SELECT tenant_id FROM users WHERE id = auth.uid()
));

-- その上で PERMISSIVE Policy で具体的な操作を制御
CREATE POLICY "Authors edit own posts" ON posts
FOR UPDATE USING (auth.uid() = author_id);

この組み合わせの効果:ユーザーは正しいテナントに属している必要があり(RESTRICTIVE)、かつ作者でないと編集できない(PERMISSIVE)。

マルチテナント分離の完全パターン

SaaS アプリケーションがあり、各企業クライアントが1つのテナントと仮定します。ユーザーテーブルに tenant_id があり、すべてのビジネステーブルをテナントで分離する必要があります。

-- ユーザーテーブル
CREATE TABLE users (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  tenant_id UUID NOT NULL,
  email TEXT,
  sso_provider_id TEXT  -- SSO ユーザーのみこの値を持つ
);

-- ビジネステーブル
CREATE TABLE projects (
  id UUID PRIMARY KEY,
  tenant_id UUID NOT NULL,
  name TEXT,
  created_by UUID REFERENCES users(id)
);

-- RLS を有効化
ALTER TABLE projects ENABLE ROW LEVEL SECURITY;

-- グローバルテナント分離(RESTRICTIVE)
CREATE POLICY "Tenant isolation" ON projects
AS RESTRICTIVE TO authenticated
USING (tenant_id = (
  SELECT tenant_id FROM users WHERE id = auth.uid()
));

-- 具体的な操作権限(PERMISSIVE)
CREATE POLICY "Tenant users can view" ON projects
FOR SELECT TO authenticated
USING (true);  -- すでに RESTRICTIVE でフィルタリング済み、ここでは通す

CREATE POLICY "Project creators can edit" ON projects
FOR UPDATE TO authenticated
USING (created_by = auth.uid());

このように設定すると、各クエリに自動的にテナントフィルタが適用されます。ビジネスコードで SELECT * FROM projects を書いても、データベースは現在のテナントのデータのみを返します。

SSO ユーザーのマルチテナント分離

SSO ログインのユーザーは sso_provider_id で分離できます。JWT にはログイン方法を示すフィールドがあります:

-- SSO ユーザー用の RLS Policy
CREATE POLICY "SSO tenant isolation" ON organization_settings
AS RESTRICTIVE TO authenticated
USING (sso_provider_id = (
  SELECT auth.jwt()#>>'{amr,0,provider}'
));

auth.jwt()#>>'{amr,0,provider}' は JWT からログイン方法の provider ID を抽出します。SSO ログインの場合、この値が sso_provider_id になります。

パフォーマンス最適化のポイント

RLS Policy は暗黙的な WHERE 句です。各クエリに自動的に追加され、サブクエリが含まれる可能性があります。これはパフォーマンスに影響します。

いくつかの最適化のアドバイス:

1. Policy 内のフィールドにインデックスを作成

CREATE INDEX idx_posts_author ON posts(author_id);
CREATE INDEX idx_projects_tenant ON projects(tenant_id);

2. Policy 内のサブクエリを避ける

サブクエリは各行で実行されるため、オーバーヘッドが大きいです。より良い方法は、JWT に tenant_id を保存し、直接 auth.jwt() で抽出することです:

-- 非推奨:サブクエリ
USING (tenant_id = (SELECT tenant_id FROM users WHERE id = auth.uid()))

-- 推奨:JWT に tenant_id を保存
USING (tenant_id = (auth.jwt()->>'tenant_id')::uuid)

後述の Custom Access Token Hook で tenant_id を JWT に入れる方法を説明します。

3. SECURITY DEFINER 関数で複雑なロジックをカプセル化

Policy 内の複雑なロジックは関数にカプセル化できます。関数を SECURITY DEFINER に設定することで、毎回重複実行を避けます:

CREATE FUNCTION current_tenant_id() RETURNS UUID
LANGUAGE SQL STABLE SECURITY DEFINER AS $$
  SELECT tenant_id FROM users WHERE id = auth.uid();
$$;

-- Policy 内で関数を呼び出す
CREATE POLICY "Tenant isolation" ON projects
AS RESTRICTIVE USING (tenant_id = current_tenant_id());

STABLE は同じトランザクション内で戻り値が変わらないことを示し、PostgreSQL は1回のみ実行するように最適化します。

四、Custom Claims と RBAC の実装

JWT にはデフォルトで Supabase の組み込みフィールドしかありません:ユーザー ID、メール、ロール(authenticated/anon)など。しかし、多くの場合、より多くの情報が必要です。ユーザーロール(admin/moderator)、テナント ID、権限リストなど。

Supabase は Custom Access Token Hook を提供しており、JWT が発行される前にその内容を変更できます。

なぜ Custom Claims が必要か

典型的なシナリオ:

  1. RBAC 権限制御:JWT にユーザーロールを保存し、RLS Policy でロールに基づいて権限を判断
  2. マルチテナント分離:JWT に tenant_id を保存し、Policy 内のサブクエリを回避
  3. JWT サイズの削減:デフォルトの JWT には多くのフィールドが含まれ、SSR シナリオでの転送オーバーヘッドが大きいため、不要なものを削除

Supabase の JWT にはデフォルトで多くのフィールドがあります(session_id、aal、amr など)。各リクエストでこれを送信する必要があります。大量の SSR ページがあるアプリケーションでは、JWT が cookie で転送され、サイズがパフォーマンスに影響します。

Custom Access Token Hook の実装

Hook は PL/pgSQL 関数で、JWT 生成前に実行されます。claims を追加、変更、削除できます。

-- Hook 関数を作成
CREATE OR REPLACE FUNCTION public.custom_access_token_hook(event jsonb)
RETURNS jsonb
LANGUAGE plpgsql
AS $$
DECLARE
  claims jsonb;
  user_role text;
  tenant_id uuid;
BEGIN
  -- event から claims を取得
  claims := event->'claims';

  -- user_roles テーブルからユーザーロールを取得
  SELECT role INTO user_role
  FROM public.user_roles
  WHERE user_id = (event->>'user_id')::uuid;

  -- ユーザーにロールがあれば、claims に追加
  IF user_role IS NOT NULL THEN
    claims := jsonb_set(claims, '{user_role}', to_jsonb(user_role));
  END IF;

  -- users テーブルから tenant_id を取得
  SELECT tenant_id INTO tenant_id
  FROM public.users
  WHERE id = (event->>'user_id')::uuid;

  IF tenant_id IS NOT NULL THEN
    claims := jsonb_set(claims, '{tenant_id}', to_jsonb(tenant_id));
  END IF;

  -- 修正した claims を返す
  RETURN jsonb_build_object('claims', claims);
END;
$$;

-- supabase_auth_admin にこの関数の実行権限を付与
GRANT EXECUTE ON FUNCTION public.custom_access_token_hook(jsonb)
TO supabase_auth_admin;

-- Supabase Dashboard でこの Hook を有効化
-- Authentication > Hooks > Custom Access Token > 上の関数を選択

Hook の入力パラメータ event には以下が含まれます:

  • user_id:現在のユーザー UUID
  • claims:現在の JWT claims
  • authentication_method:ログイン方法(password、oauth、sso/saml など)

返された claims は最終的な JWT にマージされます。

RBAC テーブル構造設計

完全なロール権限システムには数枚のテーブルが必要です:

-- ロールタイプを定義
CREATE TYPE app_role AS ENUM ('admin', 'moderator', 'user');

-- 権限タイプを定義
CREATE TYPE app_permission AS ENUM (
  'posts.delete',      -- 任意の投稿を削除
  'posts.pin',         -- 投稿をピン留め
  'users.manage',      -- ユーザー管理
  'settings.edit'      -- 設定の編集
);

-- ユーザー-ロールテーブル
CREATE TABLE public.user_roles (
  user_id UUID REFERENCES auth.users(id) ON DELETE CASCADE,
  role app_role NOT NULL,
  PRIMARY KEY (user_id, role)
);

-- ロール-権限テーブル
CREATE TABLE public.role_permissions (
  role app_role NOT NULL,
  permission app_permission NOT NULL,
  PRIMARY KEY (role, permission)
);

-- デフォルト権限を挿入
INSERT INTO role_permissions (role, permission) VALUES
  ('admin', 'posts.delete'),
  ('admin', 'posts.pin'),
  ('admin', 'users.manage'),
  ('admin', 'settings.edit'),
  ('moderator', 'posts.delete'),
  ('moderator', 'posts.pin');

authorize() 権限チェック関数

ロールと権限テーブルができたら、ユーザーがある権限を持っているかをチェックする関数が必要です:

CREATE OR REPLACE FUNCTION public.authorize(
  requested_permission app_permission
)
RETURNS boolean
LANGUAGE plpgsql
STABLE SECURITY DEFINER
AS $$
DECLARE
  user_role app_role;
BEGIN
  -- JWT からユーザーロールを取得
  SELECT (auth.jwt()->>'user_role')::app_role
  INTO user_role;

  -- JWT にロールがなければ false を返す
  IF user_role IS NULL THEN
    RETURN false;
  END IF;

  -- ロールがその権限を持っているかチェック
  RETURN EXISTS (
    SELECT 1 FROM public.role_permissions
    WHERE role = user_role
    AND permission = requested_permission
  );
END;
$$;

-- authenticated ロールに実行権限を付与
GRANT EXECUTE ON FUNCTION public.authorize(app_permission)
TO authenticated;

RLS Policy で authorize() を呼び出す

これで Policy 内で authorize() を使って権限をチェックできます:

-- admin/moderator のみ投稿を削除可能
CREATE POLICY "Role-based delete" ON posts
FOR DELETE TO authenticated
USING (
  authorize('posts.delete') OR auth.uid() = author_id
);

-- admin のみ投稿をピン留め可能
CREATE POLICY "Admin pin posts" ON posts
FOR UPDATE TO authenticated
USING (
  NOT is_pinned OR authorize('posts.pin')
);

最初の Policy のロジック:ユーザーは投稿を削除できます。posts.delete 権限があるか(admin/moderator)、または投稿の作者であるか。

2番目の Policy のロジック:投稿を編集する際、is_pinned を true に設定するには posts.pin 権限が必要です。

フロントエンドで Custom Claims を読み取る

Custom Claims は JWT 内にあるため、フロントエンドで access_token をデコードして読み取る必要があります:

import { jwtDecode } from 'jwt-decode'

// セッションを取得
const { data: { session } } = await supabase.auth.getSession()

if (session) {
  const decoded = jwtDecode(session.access_token)
  console.log('ユーザーロール:', decoded.user_role)
  console.log('テナントID:', decoded.tenant_id)
}

jwtDecode は JWT をデコードするだけで、署名を検証しません。フロントエンドでは検証の必要がありません。検証は Supabase サーバー側で行われます。

五、企業 SaaS 完全シナリオ実践

これまで学んだことを組み合わせて、完全な企業 SaaS 認証ソリューションを構築します。

シナリオ:あなたの SaaS 製品には2種類のユーザーがいます:

  1. 企業ユーザー:クライアント企業の SSO でログイン(Okta/Azure AD)、アカウントはあるテナントに属する
  2. 個人ユーザー:Google/GitHub OAuth でログイン、テナントには属さず、公開リソースのみアクセス可能

データ分離要件:企業ユーザーは自分のテナントのデータのみ閲覧可能、個人ユーザーは企業データを閲覧不可。

ユーザーテーブル設計

CREATE TABLE public.users (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  email TEXT NOT NULL,

  -- 認証ソース
  auth_type TEXT NOT NULL DEFAULT 'oauth',  -- 'oauth' または 'sso'

  -- SSO ユーザー専用フィールド
  sso_provider_id TEXT,  -- SSO 接続の ID
  tenant_id UUID,        -- 所属テナント

  -- 基本情報
  full_name TEXT,
  avatar_url TEXT,

  -- タイムスタンプ
  created_at TIMESTAMPTZ DEFAULT now(),
  updated_at TIMESTAMPTZ DEFAULT now()
);

-- テナントテーブル
CREATE TABLE public.tenants (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  name TEXT NOT NULL,
  sso_provider_id TEXT NOT NULL UNIQUE,  -- SSO 接続と関連付け
  plan_type TEXT NOT NULL DEFAULT 'team',  -- 'team' または 'enterprise'
  created_at TIMESTAMPTZ DEFAULT now()
);

Custom Access Token Hook で統一処理

Hook は2種類のユーザーを区別する必要があります:

CREATE OR REPLACE FUNCTION public.custom_access_token_hook(event jsonb)
RETURNS jsonb
LANGUAGE plpgsql
AS $$
DECLARE
  claims jsonb;
  user_record RECORD;
BEGIN
  claims := event->'claims';

  -- ユーザー情報を取得
  SELECT auth_type, sso_provider_id, tenant_id, role
  INTO user_record
  FROM public.users
  WHERE id = (event->>'user_id')::uuid;

  -- ユーザーが存在しない(新規登録)、スキップ
  IF user_record IS NULL THEN
    RETURN jsonb_build_object('claims', claims);
  END IF;

  -- 認証タイプを追加
  claims := jsonb_set(claims, '{auth_type}', to_jsonb(user_record.auth_type));

  -- SSO ユーザーに tenant_id と sso_provider_id を追加
  IF user_record.auth_type = 'sso' THEN
    IF user_record.tenant_id IS NOT NULL THEN
      claims := jsonb_set(claims, '{tenant_id}', to_jsonb(user_record.tenant_id));
    END IF;
    IF user_record.sso_provider_id IS NOT NULL THEN
      claims := jsonb_set(claims, '{sso_provider_id}', to_jsonb(user_record.sso_provider_id));
    END IF;
  END IF;

  -- ユーザーロールを追加(あれば)
  IF user_record.role IS NOT NULL THEN
    claims := jsonb_set(claims, '{user_role}', to_jsonb(user_record.role));
  END IF;

  RETURN jsonb_build_object('claims', claims);
END;
$$;

ログイン後のユーザー作成フロー

OAuth と SSO ログインはどちらも auth.users テーブルにレコードを作成します。ログイン成功後、ユーザー情報を public.users テーブルに同期する必要があります。

Auth Hooks の mfa_verification_hook またはビジネスコードで処理できます:

// auth callback ページで、ログイン成功後
const { data: { user } } = await supabase.auth.getUser()

if (user) {
  // ユーザーが public.users に存在するかチェック
  const { data: existingUser } = await supabase
    .from('users')
    .select('id')
    .eq('id', user.id)
    .single()

  if (!existingUser) {
    // ログイン方法を判定
    const authType = user.app_metadata?.provider || 'oauth'

    // ユーザーレコードを作成
    await supabase.from('users').insert({
      id: user.id,
      email: user.email,
      auth_type: authType.startsWith('sso') ? 'sso' : 'oauth',
      sso_provider_id: user.app_metadata?.sso_provider_id,
      tenant_id: null,  // 後で管理者が割り当て
      full_name: user.user_metadata?.full_name,
      avatar_url: user.user_metadata?.avatar_url
    })
  }
}

統一された RLS Policy

ビジネステーブルには統一された RLS Policy が必要で、OAuth ユーザーと SSO ユーザーの両方を処理します:

-- projects テーブルがあると仮定
CREATE TABLE public.projects (
  id UUID PRIMARY KEY,
  tenant_id UUID REFERENCES tenants(id),
  name TEXT NOT NULL,
  is_public BOOLEAN DEFAULT false,
  created_by UUID REFERENCES users(id)
);

ALTER TABLE public.projects ENABLE ROW LEVEL SECURITY;

-- グローバル分離 Policy(RESTRICTIVE)
CREATE POLICY "Tenant or public access" ON public.projects
AS RESTRICTIVE TO authenticated
USING (
  -- SSO ユーザー:tenant_id が一致する必要がある
  (auth.jwt()->>'auth_type' = 'sso'
   AND tenant_id = (auth.jwt()->>'tenant_id')::uuid)
  OR
  -- OAuth ユーザー:公開プロジェクトのみ閲覧可能
  (auth.jwt()->>'auth_type' = 'oauth' AND is_public = true)
);

-- 閲覧を許可(PERMISSIVE)
CREATE POLICY "Users can view" ON public.projects
FOR SELECT TO authenticated
USING (true);

-- 作成を許可(テナントユーザーのみ作成可能)
CREATE POLICY "Tenant users can create" ON public.projects
FOR INSERT TO authenticated
WITH CHECK (
  auth.jwt()->>'auth_type' = 'sso'
  AND tenant_id = (auth.jwt()->>'tenant_id')::uuid
);

-- 編集を許可(作成者または管理者)
CREATE POLICY "Creators or admins can edit" ON public.projects
FOR UPDATE TO authenticated
USING (
  created_by = auth.uid()
  OR authorize('projects.edit')
);

この Policy セットのロジック:

  • SSO ユーザーは自分のテナントのプロジェクトのみ閲覧可能
  • OAuth ユーザーは公開プロジェクトのみ閲覧可能
  • SSO ユーザーのみプロジェクトを作成可能(テナントに属する必要がある)
  • 編集権限:作成者または projects.edit 権限を持つ管理者

企業クライアントの管理者がテナントを割り当て

SSO ユーザーはログイン直後、tenant_id が null です。企業管理者が手動で割り当てる必要があります:

-- ユーザーをテナントに追加
UPDATE public.users
SET tenant_id = '<テナントUUID>'
WHERE id = '<ユーザーUUID>';

-- ユーザーロールを設定
INSERT INTO public.user_roles (user_id, role)
VALUES ('<ユーザーUUID>', 'admin');

このプロセスは管理画面を作成するか、Auth Hooks で自動処理(SSO provider に基づいてテナントに匿名マッピング)できます。

完全なフロー図

ユーザーログイン

   ├─ OAuth ユーザー
   │    │
   │    ├─ Google/GitHub コールバック
   │    ├─ Supabase が auth.users を作成
   │    ├─ ビジネスコードが public.users を作成 (auth_type='oauth')
   │    └─ JWT: { auth_type: 'oauth' }
   │    │
   │    └─ RLS: is_public=true のデータのみ閲覧可能

   └─ SSO ユーザー

        ├─ Okta/Azure AD SAML コールバック
        ├─ Supabase が auth.users を作成 (sso_provider_id 付き)
        ├─ ビジネスコードが public.users を作成 (auth_type='sso')
        ├─ 管理者が tenant_id を割り当て
        ├─ Custom Hook が tenant_id を JWT に追加
        └─ JWT: { auth_type: 'sso', tenant_id: '...' }

        └─ RLS: tenant_id が一致するデータのみ閲覧可能

Supabase Auth エンタープライズ級設定の完全フロー

OAuth ソーシャルログインから SAML SSO 企業統合、RLS マルチテナント権限分離までの完全な設定手順

⏱️ 目安時間: 2 時間

  1. 1

    ステップ1: OAuth プロバイダーを設定(Google/GitHub/Apple)

    1. プロバイダーコンソールで OAuth App を作成(Google Cloud Console / GitHub Settings)
    2. コールバック URL を設定:https://<プロジェクトref>.supabase.co/auth/v1/callback
    3. Supabase Dashboard でプロバイダーを有効化し、Client ID と Secret を入力
    4. コードから signInWithOAuth() を呼び出し、スコープと redirectTo を指定
  2. 2

    ステップ2: SAML SSO 企業統合を設定

    1. 企業 IdP(Okta/Azure AD)から Metadata ファイルまたは URL を取得
    2. CLI で SSO 接続を追加:supabase sso add --type saml --domains company.com
    3. Attribute Mapping を設定し、email、name などのフィールドをマッピング
    4. ログインフローをテスト:ユーザーがメールを入力すると自動的に IdP にリダイレクト
  3. 3

    ステップ3: RLS マルチテナント分離を実装

    1. ビジネステーブルに tenant_id フィールドを追加
    2. RLS を有効化:ALTER TABLE projects ENABLE ROW LEVEL SECURITY
    3. RESTRICTIVE ポリシーを作成し、グローバルテナントフィルタリングを実装
    4. PERMISSIVE ポリシーを作成し、具体的な操作権限を制御(SELECT/INSERT/UPDATE)
    5. tenant_id フィールドにインデックスを追加し、パフォーマンスを最適化
  4. 4

    ステップ4: Custom Access Token Hook を設定

    1. public.custom_access_token_hook() 関数を作成
    2. 関数内で tenant_id、user_role などのカスタムクレームを追加
    3. supabase_auth_admin に関数の実行権限を付与
    4. Supabase Dashboard で Hook を有効化(Authentication > Hooks)
    5. フロントエンドで jwtDecode() でカスタムクレームを読み取り
  5. 5

    ステップ5: RBAC 権限制御を実装

    1. user_roles と role_permissions テーブルを作成
    2. app_role と app_permission 列挙型を定義
    3. authorize() 関数を作成し、ユーザー権限をチェック
    4. RLS Policy 内で authorize('permission.name') を呼び出し
    5. フロントエンドで JWT 内の user_role に基づいて UI 要素を表示/非表示

FAQ

OAuth と SAML SSO の違いは何ですか?どちらを選ぶべきですか?
OAuth は個人コンシューマーアプリケーションに適しています。ユーザーは Google/GitHub アカウントでログインし、設定が簡単で、メンテナンスコストが低いです。SAML SSO は B2B 企業アプリケーションに適しています。企業クライアントは従業員に会社の統一アカウント(Okta/Azure AD)でのログインを要求し、アカウントは企業が管理し、従業員が退職すると自動的に無効になります。

B2B SaaS を開発する場合、SSO は必須機能です。個人向け製品であれば、OAuth で十分です。
RLS を有効にした後、クエリが空の結果を返します。どうすればいいですか?
これは RLS のデフォルト動作です:有効にするとデフォルトですべてのアクセスが拒否されます。アクセスを許可するには Policy を作成する必要があります。例:CREATE POLICY "Users view own posts" ON posts FOR SELECT TO authenticated USING (auth.uid() = author_id);
RLS Policy のパフォーマンスが悪い場合、どう最適化すればいいですか?
3つの最適化方向があります:

• Policy 内のフィールドにインデックスを作成:CREATE INDEX idx_tenant ON projects(tenant_id);
• Policy 内のサブクエリを回避:Custom Access Token Hook で tenant_id を JWT に入れ、auth.jwt()->>'tenant_id' で直接抽出
• SECURITY DEFINER 関数で複雑なロジックをカプセル化、PostgreSQL は1回のみ実行するように最適化
JWT にカスタムフィールド(tenant_id、user_role など)を保存するにはどうすればいいですか?
Supabase の Custom Access Token Hook を使用します:

1. PL/pgSQL 関数 custom_access_token_hook(event jsonb) を作成
2. 関数内で jsonb_set() を使ってカスタムクレームを追加
3. Supabase Dashboard で Hook を有効化(Authentication > Hooks > Custom Access Token)
4. フロントエンドで jwtDecode() で JWT 内のカスタムクレームを読み取り
マルチテナント SaaS で企業ユーザーと個人ユーザーのデータ分離を実現するにはどうすればいいですか?
核心となる考え方:JWT に auth_type('oauth' または 'sso')と tenant_id を保存し、RLS Policy でこの2つのフィールドに基づいてデータをフィルタリング:

• SSO ユーザー:tenant_id が一致するテナントデータのみ閲覧可能
• OAuth ユーザー:is_public=true の公開データのみ閲覧可能

RESTRICTIVE ポリシーをグローバルフィルターとして使用し、PERMISSIVE ポリシーで具体的な操作を制御します。
Supabase は Apple Sign In に対応していますか?設定は複雑ですか?
対応していますが、Google/GitHub より設定が複雑です:

1. Apple Developer Portal で Services ID を作成
2. 秘密鍵を生成(.p8 ファイル、1回のみダウンロード可能)
3. Supabase Dashboard で Team ID、Key ID、Services ID、秘密鍵の内容を入力

iOS アプリではネイティブの Sign in with Apple API を使用し、identity_token を取得して Supabase の signInWithIdTokenCredentials() に渡すことができます。
企業クライアントが SSO を要求していますが、彼らの IdP タイプがわかりません。どうすればいいですか?
クライアントの IT 管理者に IdP Metadata ファイルまたは URL(Okta/Azure AD/Google Workspace すべてエクスポート対応)を提供してもらいます。Metadata を入手すれば、supabase sso add --metadata-file コマンドで接続を追加するだけです。Supabase は Metadata 内のエンドポイントと証明書を自動的に解析します。

OAuth ソーシャルログインから始まり、SAML SSO 企業統合へと進み、最後に RLS と Custom Claims で完全な権限体系を構築しました。このソリューションは、個人製品から企業 SaaS まで、様々なシナリオをカバーできます。

いくつかの重要な判断ポイント:

ユーザーが主に個人消費者の場合:OAuth(Google/GitHub)で十分です。設定が簡単で、ユーザーにとって馴染みがあり、メンテナンスコストが低いです。RLS の基本バージョン——ユーザーは自分のデータにのみアクセス可能——で十分です。

B2B SaaS を開発している場合:SSO は必須機能です。企業クライアントが要求し、なければ直接却下される可能性があります。Okta/Azure AD/Google Workspace の統合を準備し、マルチテナントアーキテクチャを予め用意しておきましょう。

複雑な企業アプリケーションを開発している場合:RBAC + RLS の組み合わせが標準ソリューションです。ロール権限テーブル、Custom Claims、authorize() 関数——この組み合わせで「管理者は削除できるがピン留めできない」「テナントメンバーは編集できるが削除できない」などのきめ細かい権限要件に対応できます。

次のステップのおすすめ:まず OAuth から始め、基本フローを確立しましょう。プロジェクトが成熟し、企業クライアントのニーズが出てきたら、SSO と RBAC を追加します。Supabase のアーキテクチャは段階的なアップグレードをサポートしているため、最初からすべての機能を設定する必要はありません。

8 min read · 公開日: 2026年4月21日 · 更新日: 2026年4月25日

関連記事

コメント

GitHubアカウントでログインしてコメントできます