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.
| Layer | Enforces | Trust level |
|---|---|---|
| UI conditionals | What is shown | None — cosmetic only |
| Route guard / middleware | Page access | Low — improves UX |
| Server handler check | Action permission | High |
| Row-level security policy | Data access | Authoritative |
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.