# RBAC (role-based access control) with Supabase RLS: designing roles and permissions with custom claims, the authorize() function, and app_metadata

> An implementation guide to the official pattern for integrating RBAC into RLS with Supabase (PostgreSQL). With official-compliant real code, it explains: app_role/app_permission enums and user_roles/role_permissions tables, putting a role claim into the JWT with custom_access_token_hook, judging permission with a security-definer authorize() function, calling authorize() from RLS policies, token refresh after a role change, and the safe distinction between app_metadata and user_metadata.

- Published: 2026-06-28
- Author: 友田 陽大
- Tags: Supabase, RLS, PostgreSQL, セキュリティ, アーキテクチャ設計
- URL: https://tomodahinata.com/en/blog/supabase-rls-rbac-custom-claims-app-metadata-authorize-guide
- Category: Databases & RLS
- Pillar guide: https://tomodahinata.com/en/blog/supabase-production-guide-nextjs-rls-realtime-edge-functions

## Key points

- RBAC integrates into RLS in three stages of 'role → permission → policy.' It's a concern orthogonal to tenant isolation (row ownership); layer them without mixing.
- Normalize roles into user_roles and permissions into role_permissions (two tables), and put the role claim into the JWT at issuance with custom_access_token_hook. Don't hit the DB on every check.
- authorize(permission) is a security definer, search_path='' SQL function that matches auth.jwt()'s role claim against role_permissions and returns a boolean. The policy calls it with (select authorize('...')).
- Only tamper-proof claims (app_metadata or a hook-derived role) may be used for authorization. Using user_metadata, which the user can rewrite, for authorization is a privilege-escalation hole.
- A JWT is a snapshot. Change a role and the existing token stays old until it expires. For permissions needing immediate revocation, a DB lookup; for slow-changing permissions, the claim — use them differently.

---

"Tenant isolation is done with RLS. But **within the same tenant, how do I express 'only an admin can delete' or 'a viewer only reads'?**" — build out a multi-tenant SaaS and you always hit this wall. This is the realm of **RBAC (Role-Based Access Control)**, the story of "**what may you do**" (roles, permissions), orthogonal to "**which row**" (ownership, tenant).

What's important is to **layer these two without mixing.** Tenant isolation with `tenant_id`, RBAC with "role → permission." Crudely cram both into the same policy and you get a policy that can't be read, fixed, or kept from leaking.

This article turns the **official pattern** for integrating RBAC into RLS with Supabase — putting the role into the JWT with `custom_access_token_hook`, judging permission with an `authorize()` function, and calling it from RLS policies — into an implementation guide, including the easy-to-trip "token refresh after a role change" and "the distinction between `app_metadata` and `user_metadata`." The subject is the [real-time match-recording app](/case-studies/realtime-sports-scoring-app) (players, team admins, scorers, scouts, and operators touch the same data with different views, all-RLS on 69 tables, 280 policies). The content is faithful to [Supabase official: Custom Claims & RBAC](https://supabase.com/docs/guides/database/postgres/custom-claims-and-role-based-access-control-rbac) and [RLS official](https://supabase.com/docs/guides/database/postgres/row-level-security) (as of June 2026).

> **Premise**: the basics of RLS are in [RLS for beginners](/blog/supabase-rls-getting-started-enable-first-policy-guide), and the design of tenant isolation (row ownership) is in [multi-tenant production design](/blog/supabase-rls-production-multi-tenancy-patterns). This article concentrates on "roles and permissions."

---

## 1. The map of the design: the three stages of role → permission → policy

Integrating RBAC into RLS means designing the following three stages. The order has meaning.

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

The point is to **separate "role" and "permission."** Rather than giving the user "delete permission" directly, assign the user a **role** and the role **permissions.** This way, "add one new permission to moderator" is reflected for all moderators with a one-line INSERT, and the policy side needs no touching at all (ETC: change stays in one place).

> **Why separate from tenant isolation**: `tenant_id = ...` (which row) and `authorize('messages.delete')` (what you may do) **change for different reasons** (SRP). The tenant boundary is determined by ownership, and permission by the position within the organization. In the policy, **layer them with AND** like `using (tenant_id = current_tenant() and (select authorize('messages.delete')))` — not mixing but stacking as layers is the right answer.

---

## 2. Normalize roles and permissions into two tables

First define roles and permissions with enums and normalize into two tables ([Supabase official](https://supabase.com/docs/guides/database/postgres/custom-claims-and-role-based-access-control-rbac)). Making them enums means the DB rejects typos and garbage values (type safety).

```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)
);
```

These two tables become the **single source of truth (SSoT)** of "who has which role" and "what a role can do." To add a new permission, just add a value to `app_permission` and insert one row into `role_permissions`.

---

## 3. Put the role into the JWT at issuance: custom_access_token_hook

Hitting `user_roles` on every check is slow. So use Supabase's **Auth Hook** to **bake the role into the JWT's claims at the moment of token issuance.** Put it in once, and the role is known on all subsequent requests without hitting the 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;
$$;
```

This hook **can be executed only by the Auth server (`supabase_auth_admin`)**, and narrow the permissions so general users can't see or execute it. Loosen this and a user can fabricate their own role.

```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;
```

Finally, select and enable `custom_access_token_hook` in the dashboard's **Authentication > Hooks.** Now the issued JWT carries the `user_role` claim.

---

## 4. Judge permission: the authorize() function

Now that the role is in the JWT, make a function that judges **"does this role have this permission."** Make it `security definer` (= can read `role_permissions` bypassing RLS) and always attach `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;
$$;
```

The point is that it's a hybrid that reads **the role from the JWT (`auth.jwt()`) and the permission mapping from `role_permissions`.** Since the role changes slowly, put it in the JWT for speed; since the "role → permission" mapping will grow in the future, put it in a table to keep it flexible — this division works.

---

## 5. Call authorize() from RLS policies

All that's left is to call `authorize()` in the policy. **Wrapping with `(select ...)`** is a performance optimization that prevents per-row re-evaluation ([reason](/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')) );
```

### Layer with tenant isolation (the practical form)

In a real SaaS, RBAC isn't used alone. **Layer it with the tenant boundary (ownership) via AND.** This is the concrete form of "stack without mixing."

```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'))          -- 何をしてよいか（役割/権限）
);
```

With the two concerns lined up with `and`, the policy becomes a form where **the intent is understandable on reading.** If you absolutely don't want to remove the tenant boundary, carving the tenant condition into a separate `as restrictive` policy and fixing it with AND is also effective.

---

## 6. Pitfall 1: don't mistake which claim may be used for authorization

The most dangerous accident in RBAC is **using a place the user can rewrite as the basis of authorization.** Supabase's user metadata has two kinds ([Supabase official](https://supabase.com/docs/guides/database/postgres/row-level-security)).

| Metadata | Who can rewrite it | Use for authorization? |
| --- | --- | --- |
| `raw_user_metadata` (user_metadata) | **The user themselves can update it** | ❌ Never use |
| `raw_app_metadata` (app_metadata) | Server/admin only (**user cannot**) | ✅ OK to use |
| `custom_access_token_hook`'s `user_role` | Granted by the hook (Auth server) | ✅ OK to use |

`user_metadata` can be changed by the user themselves with `supabase.auth.updateUser()`. Putting `role: 'admin'` here and authorizing on it means **the user can promote themselves to admin** — a typical privilege-escalation hole. For authorization, always use only **tamper-proof claims** (this article's hook-derived `user_role` or `app_metadata`).

If the role **rarely changes**, you can also choose a lightweight version that holds it directly in `app_metadata` without using the hook.

```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' );
```

Update `app_metadata` from the server side (service_role) with `auth.admin.updateUserById()`, and don't let the user touch it.

---

## 7. Pitfall 2: a JWT is a snapshot — a role change isn't reflected immediately

Finally, a property that always matters in operation. **A JWT is a snapshot at issuance time.** The hook **only takes effect on newly issued tokens.** So —

- Even if you promote a user from `moderator` to `admin`, **their (not-yet-expired) token stays `moderator`.**
- Conversely, even if you revoke a permission, **the old permission remains until the token expires (until refresh).**

This is a trade-off in exchange for performance (not hitting the DB). Handle it by the nature of the permission.

| Nature of the permission | Method | Reflection timing |
| --- | --- | --- |
| Slow-changing (position, plan) | JWT claim (hook/app_metadata) | At the next token refresh |
| Immediate revocation required (BAN, withdrawal, dismissal) | Read the DB's `user_roles` directly in RLS | Immediate |

For a requirement of "promotion reflected at next login is OK, revocation immediate," use **JWT for promotion and a DB check for revocation** together. When you want immediate reflection on the app side, after a role change have the token refreshed with `supabase.auth.refreshSession()` (or sign out → re-authenticate) to re-fetch the new claims.

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

The classic RBAC pitfall of "I changed the role but it's not reflected" is mostly solved by this understanding of token freshness.

---

## Conclusion: roles and permissions are "separate, then layer"

- RBAC is three stages of **role → permission → policy.** It's a concern orthogonal to tenant isolation (which row); **layer it with AND without mixing.**
- Normalize roles into `user_roles` and permissions into `role_permissions`. **Bake the role into the JWT with `custom_access_token_hook`** and don't hit the DB on every check.
- With **`authorize()` (security definer, `search_path=''`)**, match role and permission, and the policy calls it with `(select authorize('...'))`.
- Only **tamper-proof claims** may be used for authorization. **Don't use `user_metadata` for authorization** (a privilege-escalation hole).
- **A JWT is a snapshot.** For permissions needing immediate revocation, a DB check; for slow-changing permissions, the claim — use them differently, and refresh with `refreshSession()` if needed.

Once you've nailed down even the role design, finally [test each role's allow/deny with pgTAP](/blog/supabase-rls-testing-pgtap-policy-regression-guide) and stop regressions in CI. RBAC is an area prone to "adding one role broke another role's boundary" — **only tests guarantee a safe change.**

### Primary sources (always confirm the latest)

- [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)
