Skip to main content
友田 陽大
Databases & RLS
Supabase
RLS
PostgreSQL
セキュリティ
アーキテクチャ設計

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
Reading time
9 min read
Author
友田 陽大
Share

"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) 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). 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).

MetadataWho can rewrite itUse 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_roleGranted 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 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 permissionMethodReflection 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 RLSImmediate

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

友田

友田 陽大

Developer of a METI Minister's Award–winning product. With TypeScript + Python + AWS, I deliver SaaS, industry DX, and production-grade generative AI (RAG) end to end — from requirements to infrastructure and operations — single-handedly.

Got a challenge?

From design to implementation and operations — solo × generative AI

Implementation like this article's, end to end from requirements to production. Start with a free 30-minute technical consult and tell me about your situation.

Available for both project-based (contract) and advisory engagements. Start with a free 30-minute consult.

Also worth reading