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

Supabase Auth 詳細設定:OAuth・SSO・権限制御

あの日の午後、営業の同僚が駆け寄ってきてこう言いました。「お客さんが Okta でのログインを求めていて、2 週間以内に SSO を出してほしいって」。私は一瞬固まりました——私のアプリはメール登録と Google OAuth にしか対応していなかったのです。SAML?まったく触れたことがありませんでした。

こうした場面は B2B SaaS では本当によくあります。企業顧客は、自社の従業員に個別のアカウントを登録させることを受け入れません。彼らには統一された ID 管理システム(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 は遷移先 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 には scopes の設定画面がなく、scopes はコード内で指定します:

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

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

Apple OAuth の設定

Apple の設定が最も面倒です。Apple Developer Portal で Services ID を作成し、さらに秘密鍵(.p8 ファイル)を生成する必要があります。秘密鍵はダウンロードが一度きりで、紛失すると再生成しなければなりません。

主なパラメータ:

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

Supabase Dashboard でこれらの値を入力する際、Private Key はファイルの内容を丸ごとコピーして貼り付けます(BEGIN/END の 2 行も含みます)。

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

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

iOS アプリにはもう 1 つの方法があります。ネイティブの 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 は企業の ID プロバイダー(IdP)が、あなたのアプリ(Service Provider、SP)にユーザーの ID 情報を送るものです。企業にとって SAML はよりコントロールしやすく、ユーザーが退職して IdP がアカウントを無効化すれば、接続されたすべてのアプリが自動的に無効になります。

設定前の準備作業

顧客から IdP の Metadata を受け取る必要があります。このファイルには IdP の証明書やエンドポイント URL などの情報が含まれています。Okta、Azure AD、Google Workspace はいずれも Metadata をエクスポートする入口を備えています。

Supabase が提供する主要な URL:

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

ACS URL(SAML Response を受け取る URL):
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-url の代わりに --metadata-file を使い、XML ファイルを直接アップロードすることもできます:

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

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

Attribute Mapping の設定

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

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

{
  "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 社の企業顧客がいて、一方は Acme Corp(Okta を使用)、もう一方は 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. ユーザーがメールアドレスを入力する(例:john@acme.com
  2. Supabase が acme.com ドメインに SSO 設定があることを検知する
  3. 自動的に Acme の Okta ログインページへ遷移する
  4. ユーザーが Okta でアカウントとパスワードを入力する(すでにログイン済みなら、そのまま通過することも)
  5. Okta が SAML Response を Supabase に送る
  6. Supabase が検証後に session を作成し、あなたのアプリへ戻る

コード側で 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 つの clause があります:USING と WITH CHECK です。

USING clause:ユーザーがそのデータを「見られる」または「特定できる」かを判定します。SELECT、UPDATE、DELETE 操作に使います——ユーザーはまずデータを見られなければ、修正も削除もできません。

WITH CHECK clause:ユーザーがそのデータを「書き込める」かを判定します。INSERT、UPDATE 操作に使います——書き込んだ後のデータが条件を満たしている必要があります。

-- ユーザーは自分の posts だけを操作できる
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 モードで、いずれか 1 つの 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:いずれかを満たせばよい

一方、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 clause です。各クエリに自動的に付加され、サブクエリを含むこともあります。これは性能に影響します。

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

1. Policy で使うフィールドにインデックスを張る

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

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

サブクエリは各行ごとに 1 回実行され、コストが大きくなります。より良いやり方は、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 は一度だけ実行するよう最適化します。

四、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'

// session を取得する
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 が一致するデータだけを見られる

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 のアーキテクチャは段階的なアップグレードに対応しているので、最初からすべての機能を整える必要はありません。

Supabase Auth 企業向け設定の完全フロー

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

⏱️ 目安時間: 2 時間

  1. 1

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

    1. プロバイダーの管理画面で OAuth アプリを作成する(Google Cloud Console/GitHub Settings)
    2. コールバック URL を設定する:https://<プロジェクト ref>.supabase.co/auth/v1/callback
    3. Supabase Dashboard でプロバイダーを有効化し、Client ID と Secret を入力する
    4. コードで signInWithOAuth() を呼び出し、scopes と 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 Policy を作成し、全体的なテナント絞り込みを実装する
    4. PERMISSIVE Policy を作成し、具体的な操作権限を制御する(SELECT/INSERT/UPDATE)
    5. tenant_id フィールドにインデックスを追加して性能を最適化する
  4. 4

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

    1. public.custom_access_token_hook() 関数を作成する
    2. 関数内で tenant_id や user_role などの custom claims を追加する
    3. supabase_auth_admin に関数の実行権限を付与する
    4. Supabase Dashboard で Hook を有効化する(Authentication > Hooks)
    5. フロントエンドで jwtDecode() を使い custom claims を読み取る
  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 は一度だけ実行するよう最適化します
JWT にカスタムフィールド(tenant_id、user_role など)を保存するにはどうしますか?
Supabase の Custom Access Token Hook を使います:

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

• SSO ユーザー:tenant_id が一致するテナントのデータだけを見られる
• OAuth ユーザー:is_public=true の公開データだけを見られる

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

1. Apple Developer Portal で Services ID を作成する
2. 秘密鍵(.p8 ファイル、ダウンロードは一度きり)を生成する
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 内のエンドポイントと証明書を自動的に解析します。

7分で読めます · 公開日: 2026年4月21日 · 更新日: 2026年6月8日

関連記事

コメント

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