# Supabase RLSでRBAC（ロールベースアクセス制御）：custom claims・authorize()関数・app_metadataで役割と権限を設計する

> Supabase（PostgreSQL）でRBACをRLSに統合する公式パターンを実装ガイド化。app_role/app_permission列挙とuser_roles/role_permissionsテーブル、custom_access_token_hookでJWTにrole claimを載せる、security definerなauthorize()関数で権限を判定、RLSポリシーからauthorize()を呼ぶ、ロール変更後のトークン更新とapp_metadata vs user_metadataの安全な使い分けまで、公式準拠の実コードで解説します。

- 公開日: 2026-06-28
- 著者: 友田 陽大
- タグ: Supabase, RLS, PostgreSQL, セキュリティ, アーキテクチャ設計
- URL: https://tomodahinata.com/blog/supabase-rls-rbac-custom-claims-app-metadata-authorize-guide
- カテゴリ: データベース・RLS
- 総合ガイド: https://tomodahinata.com/blog/supabase-production-guide-nextjs-rls-realtime-edge-functions

## 要点

- RBACはRLSに『役割→権限→ポリシー』の3段で統合する。テナント分離（行の所有）とは直交する関心事で、混ぜずに重ねる
- 役割はuser_roles、権限はrole_permissionsの2表に正規化し、custom_access_token_hookで発行時にJWTへrole claimを載せる。判定のたびにDBを引かない
- authorize(permission)はsecurity definer・search_path=''のSQL関数で、auth.jwt()のrole claimとrole_permissionsを突き合わせて真偽を返す。ポリシーは(select authorize('...'))で呼ぶ
- 認可に使ってよいのは改ざん不可のclaim（app_metadataやhook由来のrole）だけ。ユーザーが書き換え可能なuser_metadataを認可に使うのは特権昇格の穴になる
- JWTはスナップショット。ロールを変えても既存トークンは失効まで古いまま。即時剥奪が要る権限はDBルックアップ、変化が緩い権限はclaimと使い分ける

---

「テナント分離はRLSでできた。でも**同じテナントの中で『管理者だけが削除できる』『閲覧者は読むだけ』をどう表現するか**？」——マルチテナントSaaSを作り込むと、必ずこの壁に当たります。これは**RBAC（Role-Based Access Control＝ロールベースアクセス制御）**の領域で、「**どの行か**（所有・テナント）」とは直交する「**何をしてよいか**（役割・権限）」の話です。

重要なのは、この2つを**混ぜずに重ねる**こと。テナント分離は `tenant_id` で、RBACは「役割→権限」で。両者を同じポリシーに雑に詰め込むと、読めない・直せない・漏れるポリシーになります。

この記事は、SupabaseでRBACをRLSに統合する**公式パターン**——`custom_access_token_hook` でJWTに役割を載せ、`authorize()` 関数で権限を判定し、RLSポリシーから呼ぶ——を、つまずきやすい「ロール変更後のトークン更新」「`app_metadata` と `user_metadata` の使い分け」まで含めて実装ガイド化します。題材は[リアルタイム試合記録アプリ](/case-studies/realtime-sports-scoring-app)（選手・チーム管理者・スコアラー・スカウト・運営が同じデータを別の見え方で触る、69テーブル全RLS・280ポリシー）。内容は[Supabase公式: Custom Claims & RBAC](https://supabase.com/docs/guides/database/postgres/custom-claims-and-role-based-access-control-rbac)・[RLS公式](https://supabase.com/docs/guides/database/postgres/row-level-security)（2026年6月時点）に忠実です。

> **前提**：RLSの基礎は[RLS入門](/blog/supabase-rls-getting-started-enable-first-policy-guide)、テナント分離（行の所有）の設計は[マルチテナント本番設計](/blog/supabase-rls-production-multi-tenancy-patterns)で扱いました。本記事は「役割と権限」に集中します。

---

## 1. 設計の地図：役割→権限→ポリシーの3段

RBACをRLSに統合するとは、次の3段を設計することです。順番に意味があります。

```
ユーザー ──(user_roles)──▶ 役割(role)
                              │
                              ▼ (role_permissions)
                           権限(permission)  例: 'channels.delete'
                              │
                              ▼ (RLS policy が authorize('channels.delete') を呼ぶ)
                           その操作を許可 / 拒否
```

ポイントは**「役割」と「権限」を分離する**こと。ユーザーに直接「削除権限」を付けるのではなく、ユーザーに**役割**を、役割に**権限**を割り当てる。こうすると「moderatorに新しい権限を1つ足す」が1行のINSERTで全moderatorに反映され、ポリシー側は一切触らずに済みます（ETC：変更が一箇所に閉じる）。

> **なぜテナント分離と分けるのか**：`tenant_id = ...`（どの行か）と `authorize('messages.delete')`（何をしてよいか）は**変わる理由が違う**（SRP）。テナント境界は所有で決まり、権限は組織内の役職で決まる。ポリシーでは `using (tenant_id = current_tenant() and (select authorize('messages.delete')))` のように**ANDで重ねる**——混ぜるのではなく、層として積むのが正解です。

---

## 2. 役割と権限を2つのテーブルに正規化する

まず列挙型で役割と権限を定義し、2つのテーブルに正規化します（[Supabase公式](https://supabase.com/docs/guides/database/postgres/custom-claims-and-role-based-access-control-rbac)）。列挙型にするのは、タイプミスやゴミ値をDBが弾いてくれる（型安全）からです。

```sql
-- 役割と権限を列挙型で固定する（不正な値をDBが拒否する）
create type public.app_permission as enum ('channels.delete', 'messages.delete');
create type public.app_role       as enum ('admin', 'moderator');

-- ユーザー → 役割（多対多を許す）
create table public.user_roles (
  id      bigint generated by default as identity primary key,
  user_id uuid references auth.users on delete cascade not null,
  role    app_role not null,
  unique (user_id, role)
);

-- 役割 → 権限
create table public.role_permissions (
  id         bigint generated by default as identity primary key,
  role       app_role not null,
  permission app_permission not null,
  unique (role, permission)
);
```

この2表が「誰がどの役割か」「役割が何をできるか」の**単一の真実源（SSoT）**になります。新しい権限を増やすときは `app_permission` に値を足し、`role_permissions` に1行入れるだけ。

---

## 3. 発行時にJWTへ役割を載せる：custom_access_token_hook

判定のたびに `user_roles` を引くのは遅い。そこでSupabaseの**Auth Hook**を使い、**トークン発行のタイミングで役割をJWTのクレームに焼き込みます**。一度載せれば、以降の全リクエストでDBを引かずに役割が分かります。

```sql
-- トークン発行直前に呼ばれ、claimsに user_role を追加する
create or replace function public.custom_access_token_hook(event jsonb)
returns jsonb
language plpgsql
stable
as $$
declare
  claims jsonb;
  user_role public.app_role;
begin
  select role into user_role
  from public.user_roles
  where user_id = (event->>'user_id')::uuid;

  claims := event->'claims';

  if user_role is not null then
    claims := jsonb_set(claims, '{user_role}', to_jsonb(user_role));
  else
    claims := jsonb_set(claims, '{user_role}', 'null');
  end if;

  event := jsonb_set(event, '{claims}', claims);
  return event;     -- 変更後のeventを返すとそのclaimsでトークンが発行される
end;
$$;
```

このフックは **Auth サーバー（`supabase_auth_admin`）だけが実行**でき、一般ユーザーからは見えない・実行できないように権限を絞ります。ここを緩めると、ユーザーが自分の役割を細工できてしまいます。

```sql
grant  usage   on schema public to supabase_auth_admin;
grant  execute on function public.custom_access_token_hook to supabase_auth_admin;
revoke execute on function public.custom_access_token_hook from authenticated, anon, public;

grant  all on table public.user_roles to supabase_auth_admin;
revoke all on table public.user_roles from authenticated, anon, public;
```

最後にダッシュボードの **Authentication > Hooks** で `custom_access_token_hook` を選んで有効化します。これで発行されるJWTに `user_role` クレームが載ります。

---

## 4. 権限を判定する：authorize() 関数

役割がJWTに載ったので、**「この役割は、この権限を持つか」**を判定する関数を作ります。`security definer`（＝RLSをバイパスして `role_permissions` を引ける）で、`search_path=''` を必ず付けます。

```sql
create or replace function public.authorize(
  requested_permission app_permission
)
returns boolean
language plpgsql
stable
security definer
set search_path = ''           -- search_path注入を塞ぐ（security definerの必須作法）
as $$
declare
  bind_permissions int;
  user_role public.app_role;
begin
  -- JWTに焼き込まれた役割を読む（DBのuser_rolesは引かない＝速い）
  select (auth.jwt() ->> 'user_role')::public.app_role into user_role;

  -- その役割がその権限を持つ行数を数える
  select count(*) into bind_permissions
  from public.role_permissions
  where role_permissions.permission = requested_permission
    and role_permissions.role = user_role;

  return bind_permissions > 0;
end;
$$;
```

ポイントは、**役割はJWT（`auth.jwt()`）から、権限マッピングは `role_permissions` から**引くハイブリッドであること。役割は変化が緩いのでJWTに載せて高速化し、「役割→権限」の対応は将来増えるのでテーブルに置いて柔軟に保つ——この分担が効きます。

---

## 5. RLSポリシーから authorize() を呼ぶ

あとはポリシーで `authorize()` を呼ぶだけです。**`(select ...)` で包む**のは、行ごとの再評価を防ぐパフォーマンス最適化です（[理由](/blog/supabase-rls-performance-optimization-select-wrap-index-guide)）。

```sql
-- admin/moderator のうち 'channels.delete' 権限を持つ役割だけが削除できる
create policy "Allow authorized delete on channels"
on public.channels for delete
to authenticated
using ( (select authorize('channels.delete')) );

create policy "Allow authorized delete on messages"
on public.messages for delete
to authenticated
using ( (select authorize('messages.delete')) );
```

### テナント分離と重ねる（実戦形）

実際のSaaSでは、RBACは単独では使いません。**テナント境界（所有）とANDで重ねます**。これが「混ぜずに積む」の具体形です。

```sql
-- 「同じテナントの行」かつ「messages.delete 権限を持つ役割」だけが削除できる
create policy "Tenant-scoped authorized delete"
on public.messages for delete
to authenticated
using (
  tenant_id = (select public.current_tenant_id())   -- どの行か（所有/テナント）
  and (select authorize('messages.delete'))          -- 何をしてよいか（役割/権限）
);
```

2つの関心事が `and` で並ぶことで、ポリシーが**読んで意図が分かる**形になります。テナント境界を絶対に外したくないなら、テナント条件側を `as restrictive` の別ポリシーに切り出してANDで固定するのも有効です。

---

## 6. 落とし穴1：認可に使ってよいクレームを取り違えない

RBACで最も危険な事故が、**ユーザーが書き換えられる場所を認可の根拠にする**ことです。Supabaseのユーザーメタデータには2種類あります（[Supabase公式](https://supabase.com/docs/guides/database/postgres/row-level-security)）。

| メタデータ | 誰が書き換えられるか | 認可に使う？ |
| --- | --- | --- |
| `raw_user_metadata`（user_metadata） | **ユーザー自身が更新可能** | ❌ 絶対に使わない |
| `raw_app_metadata`（app_metadata） | サーバー/管理者のみ（**ユーザー不可**） | ✅ 使ってよい |
| `custom_access_token_hook` の `user_role` | フック（Authサーバー）が付与 | ✅ 使ってよい |

`user_metadata` は `supabase.auth.updateUser()` でユーザー自身が変更できます。ここに `role: 'admin'` を置いて認可すれば、**ユーザーが自分を管理者に昇格できる**——典型的な特権昇格の穴です。認可には必ず**改ざん不可のクレーム**（本記事のhook由来 `user_role` や `app_metadata`）だけを使ってください。

役割が**滅多に変わらない**なら、フックを使わず `app_metadata` に直接持たせる軽量版も選べます。

```sql
-- app_metadata.role を使う軽量版（hookを立てない選択肢）
create policy "Admins can read all"
on public.audit_logs for select
to authenticated
using ( (select auth.jwt() -> 'app_metadata' ->> 'role') = 'admin' );
```

`app_metadata` の更新はサーバー側（service_role）から `auth.admin.updateUserById()` で行い、ユーザーに触らせないこと。

---

## 7. 落とし穴2：JWTはスナップショット——ロール変更は即時反映されない

最後に、運用で必ず効いてくる性質です。**JWTは発行時点のスナップショット**。フックは**新しく発行されるトークンにしか効きません**。だから——

- ユーザーを `moderator` から `admin` に昇格しても、**手元の（まだ失効していない）トークンは `moderator` のまま**。
- 逆に権限を剥奪しても、**トークンが失効するまで（リフレッシュまで）古い権限が残る**。

これは性能（DBを引かない）と引き換えのトレードオフです。対処は権限の性質で分けます。

| 権限の性質 | 方式 | 反映タイミング |
| --- | --- | --- |
| 変化が緩い（職位・プラン） | JWTクレーム（hook/app_metadata） | 次のトークン更新時 |
| 即時剥奪が必須（BAN・退会・解雇） | RLSでDBの `user_roles` を直接引く | 即時 |

「昇格は次回ログインで反映でOK、剥奪は即時」という要件なら、**昇格はJWT・剥奪はDBチェック**を併用します。アプリ側で即時反映したいときは、ロール変更後に `supabase.auth.refreshSession()`（またはサインアウト→再認証）で**トークンを更新**させ、新しいクレームを取り直させます。

```ts
// ロール変更を即座にクライアントへ反映したいとき、トークンを更新する
await supabase.auth.refreshSession();
```

「ロールを変えたのに反映されない」というRBACの定番のハマりは、ほぼこのトークン鮮度の理解で解けます。

---

## まとめ：役割と権限は「分けて、重ねる」

- RBACは**役割→権限→ポリシー**の3段。テナント分離（どの行か）とは直交する関心事で、**混ぜずにANDで重ねる**。
- 役割は `user_roles`、権限は `role_permissions` に正規化。**`custom_access_token_hook` でJWTに役割を焼き込み**、判定のたびにDBを引かない。
- **`authorize()`（security definer・`search_path=''`）**で役割と権限を突き合わせ、ポリシーは `(select authorize('...'))` で呼ぶ。
- 認可に使ってよいのは**改ざん不可のクレーム**だけ。**`user_metadata` を認可に使わない**（特権昇格の穴）。
- **JWTはスナップショット**。即時剥奪が要る権限はDBチェック、変化の緩い権限はクレームと使い分け、必要なら `refreshSession()` で更新する。

役割設計まで含めて固めたら、最後は[pgTAPで各役割の許可/拒否をテスト](/blog/supabase-rls-testing-pgtap-policy-regression-guide)し、CIで退行を止めてください。RBACは「役割を1つ足したら別の役割の境界が崩れた」が起きやすい領域——**テストだけが安全な変更を保証**します。

### 一次情報（必ず最新を確認してください）

- [Supabase: Custom Claims & RBAC](https://supabase.com/docs/guides/database/postgres/custom-claims-and-role-based-access-control-rbac)
- [Supabase: Row Level Security](https://supabase.com/docs/guides/database/postgres/row-level-security)
- [PostgreSQL: CREATE POLICY](https://www.postgresql.org/docs/current/sql-createpolicy.html)
