# 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.

Source: https://supahost.app/docs/auth/custom-claims-rbac · Updated 2026-11-11

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.

## Option 1: a roles table (recommended)

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.

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

```sql
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:

```sql
-- 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:

```sql
insert into public.user_roles (user_id, role)
select id, 'manager' from auth.users where email = 'morgan@example.com';
```

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

```ts
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:

```sql
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:

```ts
// 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()`:

```sql
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](/kb/multi-tenant)).

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

```ts
// 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:

```ts
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](/docs/storage/access-control) and [Realtime authorization](/docs/realtime/authorization).
