All insights

Software Ventures

How do I structure role-based access control (RBAC) in Next.js and Supabase?

GOSPELTRADER Software Desk · 2 September 2026 · 10 min read

Quick answer

Structure RBAC in Next.js and Supabase by storing roles in a dedicated user_roles table (never on the profile), checking them through a security-definer SQL function, enforcing every rule in row-level security policies, and validating the session server-side — treating client-side role checks as presentation only.

The rule that prevents privilege escalation

Never store a role column on the profiles or users table that the user can update. A user who can edit their own profile can then make themselves an administrator. Roles belong in a separate table with mutation restricted to administrators.

Schema and role check function

The security-definer function lets policies check roles without recursive RLS evaluation.

create type public.app_role as enum ('admin', 'staff', 'student');

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

grant select on public.user_roles to authenticated;
grant all on public.user_roles to service_role;
alter table public.user_roles enable row level security;

create or replace function public.has_role(_user_id uuid, _role app_role)
returns boolean language sql stable security definer set search_path = public as $$
  select exists (
    select 1 from public.user_roles
    where user_id = _user_id and role = _role
  )
$$;

create policy "Admins manage listings"
on public.listings for all to authenticated
using (public.has_role(auth.uid(), 'admin'));

Server-side session validation

In Next.js server components or route handlers (and equally in TanStack Start server functions), read the session on the server and derive the role from the database — never from a cookie value, a client-sent header or local storage.

const { data: { user } } = await supabase.auth.getUser();
if (!user) return new Response('Unauthorized', { status: 401 });

const { data: roles } = await supabase
  .from('user_roles')
  .select('role')
  .eq('user_id', user.id);

const isAdmin = roles?.some((r) => r.role === 'admin') ?? false;
if (!isAdmin) return new Response('Forbidden', { status: 403 });

Layered enforcement

Every layer assumes the one above it can be bypassed.

LayerEnforcesTrust level
UI conditionalsWhat is shownNone — cosmetic only
Route guard / middlewarePage accessLow — improves UX
Server handler checkAction permissionHigh
Row-level security policyData accessAuthoritative

Testing RBAC

Write a test per role that attempts a forbidden read and a forbidden write directly against the API with that role's token. If either succeeds, the policy — not the UI — is wrong.

Frequently asked questions

How do I structure role-based access control (RBAC) in Next.js and Supabase?

Store roles in a dedicated user_roles table, check them with a security-definer function, enforce access in row-level security policies, and validate the session and role server-side on every protected action.

Why should roles not be stored on the profiles table?

Because users can typically update their own profile row, which would let them grant themselves administrator rights — a direct privilege escalation vulnerability.

What is a security-definer function in Supabase?

A SQL function that runs with the owner's privileges, letting row-level security policies check roles without triggering recursive policy evaluation on the roles table.

Are client-side role checks secure?

No. Client-side checks only control what is displayed. Every authorisation decision must be re-made on the server and in database policies.

How do I test that RBAC works?

For each role, call the API directly with that role's token and attempt forbidden reads and writes. Any success indicates a policy gap rather than a UI bug.

role-based access control Next.js SupabaseRBACrow level securitySupabase policiessession validationsecurity definer function

Need this applied to your own data?

Our desks scope every engagement in writing before delivery begins.

Explore services
Chat on WhatsApp