"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 (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 and RLS official (as of June 2026).
Premise: the basics of RLS are in RLS for beginners, and the design of tenant isolation (row ownership) is in multi-tenant production design. 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) andauthorize('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 likeusing (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). Making them enums means the DB rejects typos and garbage values (type safety).
-- 役割と権限を列挙型で固定する(不正な値を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.
-- トークン発行直前に呼ばれ、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.
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=''.
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).
-- 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."
-- 「同じテナントの行」かつ「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).
| 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.
-- 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
moderatortoadmin, their (not-yet-expired) token staysmoderator. - 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.
// ロール変更を即座にクライアントへ反映したいとき、トークンを更新する
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_rolesand permissions intorole_permissions. Bake the role into the JWT withcustom_access_token_hookand 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_metadatafor 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 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.