Roles, custom claims and RBAC

Role-based access control for a SupaHost project: a roles table with RLS helper functions, app_metadata claims set through the admin API, and why user_metadata must never decide access.

Most apps need more than "signed in or not". A bookings app has customers, staff and managers; a team chat has workspace owners and members. This page shows two ways to model that on SupaHost and enforce it with Row Level Security.

A note on what's not here: Supabase Auth has hooks, such as a custom access token hook that adds claims to every JWT. Auth hooks aren't configurable on SupaHost (there's no setting for them), so the patterns below don't use them. Both work with the standard tokens SupaHost issues.

Keep roles in a table and look them up in policies through a small helper function. Changes take effect on the very next request, and you can manage roles with ordinary SQL.

create type public.app_role as enum ('customer', 'staff', 'manager');

create table public.user_roles (
  user_id uuid not null references auth.users (id) on delete cascade,
  role public.app_role not null,
  primary key (user_id, role)
);

alter table public.user_roles enable row level security;

create policy "Users can see their own roles"
on public.user_roles for select
to authenticated
using ((select auth.uid()) = user_id);

There are no insert, update or delete policies, so nobody can change roles through the Data API with the anon key or a user token. You assign roles from the SQL editor, a migration, or a server using the service_role key.

The helper function runs as its owner so it can read user_roles regardless of policies, and it only answers about the calling user:

create function public.has_role(wanted public.app_role)
returns boolean
language sql
stable
security definer
set search_path = ''
as $$
  select exists (
    select 1
    from public.user_roles
    where user_id = (select auth.uid())
      and role = wanted
  );
$$;

revoke execute on function public.has_role(public.app_role) from public, anon;
grant execute on function public.has_role(public.app_role) to authenticated;

Now use it in policies on your other tables:

-- Customers see their own bookings; staff and managers see all of them
create policy "Read bookings"
on public.bookings for select
to authenticated
using (
  (select auth.uid()) = customer_id
  or (select public.has_role('staff'))
  or (select public.has_role('manager'))
);

-- Only managers can cancel any booking
create policy "Managers update bookings"
on public.bookings for update
to authenticated
using ((select public.has_role('manager')))
with check ((select public.has_role('manager')));

Wrapping the call in (select ...) lets Postgres evaluate it once per query rather than once per row.

Assign a role:

insert into public.user_roles (user_id, role)
select id, 'manager' from auth.users where email = '[email protected]';

Your app can read the current user's roles to decide what to show:

const { data: roles } = await supabase.from('user_roles').select('role')
const isManager = roles?.some((r) => r.role === 'manager')

Hiding a button is only cosmetic. The policies are what stop a customer from cancelling someone else's booking.

Permissions instead of roles

If roles multiply, map them to permissions and check permissions in policies:

create table public.role_permissions (
  role public.app_role not null,
  permission text not null,       -- e.g. 'bookings.cancel'
  primary key (role, permission)
);

create function public.can(permission_name text)
returns boolean
language sql
stable
security definer
set search_path = ''
as $$
  select exists (
    select 1
    from public.user_roles ur
    join public.role_permissions rp on rp.role = ur.role
    where ur.user_id = (select auth.uid())
      and rp.permission = permission_name
  );
$$;

Then a policy reads using ((select public.can('bookings.cancel'))). Enable RLS on role_permissions too.

Option 2: claims in app_metadata

Every access token includes the user's app_metadata. Only the service_role key can change it, so it's safe to trust in policies. Set it from a server:

// Server only
await admin.auth.admin.updateUserById(userId, {
  app_metadata: { role: 'manager', venue_id: 'b7f3c1e2-0a4d-4e8f-9b6a-2c1d3e4f5a6b' },
})

Read it in policies with auth.jwt():

create policy "Staff see bookings for their venue"
on public.bookings for select
to authenticated
using (
  venue_id = ((select auth.jwt() -> 'app_metadata' ->> 'venue_id'))::uuid
);

No extra query runs, which makes this fast, and it's handy for a tenant or organization id in a multi-tenant app (see isolating tenant data).

The trade-off: claims are copied into the token when it's issued. After you change app_metadata, the user keeps their old claims until their access token refreshes, up to the Access token lifetime you set in the Sign-in & security card (one hour by default). Removing a role this way isn't instant. If that matters (a fired employee, say), use the roles table, or ban the user while the change takes effect. The client can pick up new claims sooner with supabase.auth.refreshSession().

Never use user_metadata for access

user_metadata is also in the token, and it looks just like app_metadata in a policy. But any signed-in user can change their own user_metadata:

// Any user can run this from the browser console
await supabase.auth.updateUser({ data: { role: 'manager' } })

A policy that checks auth.jwt() -> 'user_metadata' ->> 'role' would let them in. Keep user_metadata for display preferences only.

Which to choose

Roles table app_metadata
Takes effect Next request Next token refresh
Cost per request One indexed lookup, once per query None
Managed with SQL, migrations, server code Admin API from a server
Good for Roles that change, permissions, auditing Stable values like tenant id or plan

Many apps use both: a tenant id in app_metadata, roles in a table.

Checking roles outside the database

In an Edge Function or your backend, verify the user's token and read the same data:

const { data: { user } } = await admin.auth.getUser(token)
const isAdmin = user?.app_metadata?.role === 'admin'

For Storage and Realtime, the same helper functions work in their policies. See Storage access control and Realtime authorization.

Updated 11 November 2026 · View as Markdown · Something wrong or missing? Tell us