Feature spec
IntermediateCategory
auth-security
Build with AI
2–4 hours with Lovable or V0
Custom build
1 week custom dev
Running cost
$0–$25/mo (Supabase; no additional RBAC service needed for 3–5 roles)
Works on
Everything it takes to ship Custom Roles — parts, prompts, and real costs.
Custom roles need four pieces working together: a roles table defining what exists, a user_roles join table mapping users to roles, RLS policies on every data table enforcing those roles at the database level, and a React context exposing the current user's role to the UI. With Lovable or V0 you can ship a working three-role system (admin, editor, member) in 2–4 hours for $0–$25/month. The critical mistake is adding UI-level role guards without matching RLS policies — the database must enforce access, not just the frontend.
What Custom Roles Actually Are
Custom roles are how you answer 'what can this user do?' after authentication has answered 'who is this user?'. A basic three-role system gives you admins who manage the app and its users, editors who create and modify content, and members who can only read. Roles live in your database and are enforced in two places: the UI (hide buttons, redirect pages) and the database via Supabase Row Level Security policies. Both are required — UI-only role checks are a security theater that any developer can bypass by calling the Supabase REST API directly. The product decisions are how many roles you need, whether a user can hold multiple roles simultaneously, and whether role changes take effect immediately or require re-login.
What users consider table stakes in 2026
- At least three default roles out of the box: admin, editor, and member/viewer — apps with fewer roles rarely need a roles system at all
- Role assignment UI visible only to admins — a dropdown on the user list row, not a field users can edit themselves
- Role badge visible in the user's profile header and in the admin user table so the current role is always visible
- Instant UI change when an admin updates a role — role context refreshes without requiring the affected user to log out and back in
- Graceful 'Access denied' page with a back link for unauthorized route attempts — never a raw 404 or blank white screen
- Audit trail entry when any role is changed — who changed it, what it changed from/to, and when
Anatomy of the Feature
Six components make role-based access control work. The most important — and the one AI tools most often generate incompletely — is the RLS policy layer.
Roles and permissions tables
DataA roles table defines available roles (id, name, description). A user_roles join table maps users to roles (user_id uuid references auth.users, role_id int references roles, assigned_by uuid, assigned_at timestamptz). For apps that need fine-grained permission control beyond role names, a permissions table (id, action, resource) and a role_permissions join table let you define exactly what each role can do.
Note: Most apps with 3–5 roles do not need a permissions table — the role name itself (admin/editor/member) is sufficient for RLS policies. Add the permissions layer only if you have more than 5 roles or role-specific resource restrictions.
Role check middleware
BackendA server-side function that reads the user's role(s) from user_roles and compares against the required permission for the route or action. In Next.js App Router, middleware.ts reads the role from the JWT custom claim (set via Supabase Auth hook) or queries user_roles directly. In Lovable (React Router), an AuthGuard component reads from the role context.
Note: Prefer embedding the role in the Supabase JWT as a custom claim so every request includes the role without an extra DB query. Use pg_net and a Supabase Auth hook (trigger on auth.users token refresh) to populate the claim.
Admin panel — user management page
UIA table of all users with their current role and a dropdown to change it. The role update must go through a Supabase Edge Function using the service role key — not the anon key — because only privileged server-side code should be able to UPDATE user_roles. The admin panel page itself must redirect non-admins to /access-denied before rendering.
Note: Never let the anon key perform role changes. An API call from the browser with the anon key and an RLS policy that allows admins to update user_roles is technically correct but riskier than an Edge Function — the service role key bypasses RLS entirely and gives you full control.
Protected route guard
UIA React component that reads the current user's role from context and either renders its children or returns an Access Denied screen. For admin-only routes (/admin/*), the guard checks role === 'admin'. For editor routes, it checks role === 'admin' || role === 'editor'. The guard must also handle the loading state while the role is being fetched from Supabase.
Note: UI-level route guards are necessary for UX but insufficient for security. Every route guard must have a corresponding RLS policy on the data tables that route reads from.
Role context provider
UIA React context that wraps the app and stores the current user's role and a hasPermission(action, resource) helper. Loaded once on auth state change (onAuthStateChange fires SIGNED_IN) by querying user_roles for the current user's role. Exposes role to all components without prop drilling.
Note: When a user's role changes (admin updated it), call supabase.auth.refreshSession() to get a new JWT with the updated custom claim, then re-query user_roles to update the context. This allows instant role reflection without re-login.
Supabase custom JWT claims
BackendA Supabase Auth hook (trigger on auth.users that fires on each token refresh) can embed the user's role into the JWT access token as a custom claim. RLS policies can then use auth.jwt() ->> 'role' to check the role without an extra DB lookup on every request. Set up in Supabase Dashboard → Authentication → Hooks.
Note: Custom JWT claims are the most performant approach for high-traffic apps but add complexity — the claim is stale until the next token refresh (up to 1 hour by default). For apps where role changes must be instant, query user_roles directly rather than relying on the JWT claim.
The data model
Three tables handle the full RBAC pattern. Run in the Supabase SQL editor (Dashboard → SQL Editor → New Query):
1create table public.roles (2 id serial primary key,3 name text unique not null,4 description text5);67-- Seed default roles8insert into public.roles (name, description) values9 ('admin', 'Full access: manage users, content, and settings'),10 ('editor', 'Create and edit content; cannot manage users'),11 ('member', 'Read-only access to content');1213create table public.user_roles (14 user_id uuid references auth.users(id) on delete cascade not null,15 role_id int references public.roles(id) on delete restrict not null,16 assigned_by uuid references auth.users(id),17 assigned_at timestamptz not null default now(),18 primary key (user_id, role_id)19);2021alter table public.user_roles enable row level security;2223create policy "Users can view own roles"24 on public.user_roles for select25 using (auth.uid() = user_id);2627-- Admin role assignment: only service role key (Edge Function) can INSERT/DELETE28-- No anon INSERT/DELETE policy — intentional2930create index user_roles_user_idx on public.user_roles (user_id);3132-- Helper function: get current user role name33create or replace function public.get_my_role()34returns text as $$35 select r.name36 from public.user_roles ur37 join public.roles r on r.id = ur.role_id38 where ur.user_id = auth.uid()39 limit 1;40$$ language sql security definer stable;4142-- Example: RLS on a content table using role check43-- (Adapt this pattern for every protected table in your app)44create table public.posts (45 id uuid primary key default gen_random_uuid(),46 title text not null,47 body text,48 author_id uuid references auth.users(id) not null,49 created_at timestamptz not null default now()50);5152alter table public.posts enable row level security;5354create policy "Members and above can read posts"55 on public.posts for select56 using (57 public.get_my_role() in ('admin', 'editor', 'member')58 );5960create policy "Editors and admins can insert posts"61 on public.posts for insert62 with check (63 public.get_my_role() in ('admin', 'editor')64 );6566create policy "Admins can delete posts"67 on public.posts for delete68 using (69 public.get_my_role() = 'admin'70 );Heads up: The get_my_role() function is security definer (runs with DB owner privileges) so it can query user_roles without the caller needing direct table access. Apply the same RLS pattern to every data table in your app — the UI guard and the DB policy must match.
Build it — pick your path
Each tool fits this feature differently. Switch tabs to compare fit, time, the exact steps, and a copy-paste prompt.
Full control over hierarchical roles, wildcard permissions, time-limited grants, and per-resource access control. Required when you need more than five roles or dynamic permission management.
Step by step
- 1Design the full roles and permissions schema: roles, permissions, role_permissions, user_roles tables with a hierarchical inheritance model if needed
- 2Implement a get_effective_permissions(user_id) function that resolves inherited permissions through the role hierarchy
- 3Embed the resolved permissions in the Supabase JWT as a custom claim array via Auth hook — avoids per-request DB permission lookups
- 4Build a permissions management UI where admins can add and remove specific permissions from roles without code changes
- 5Synchronize RLS policies with the permissions table using a code generation step or dynamic policy evaluation
Where this path bites
- Hierarchical role inheritance (admin inherits all editor permissions) adds significant schema complexity that must be carefully tested
- Dynamic RLS policies based on a permissions table require either generated SQL or a security definer function that is called on every row access — adds query overhead
Third-party services you'll need
Custom roles are entirely handled by Supabase — no dedicated RBAC service needed for 3–5 roles:
| Service | What it does | Free tier | Paid from |
|---|---|---|---|
| Supabase | Roles schema, user_roles table, RLS policies, Edge Functions for role assignment, get_my_role() function | Free tier covers RBAC for small apps; 500MB DB, 2 projects | $25/mo Pro for production SLA and higher Edge Function quota |
| Clerk (alternative) | Built-in roles and permissions management UI if you prefer not to build the admin panel yourself | Free for 10,000 MAU | $25/mo Pro for RBAC features |
Swipe the table sideways to see pricing.
What it costs to run
Drag through the tiers to see how your monthly bill scales with users — no surprises later.
Estimated monthly running cost
Supabase free tier covers RBAC completely. Role check overhead at this scale is negligible. Custom RBAC logic runs in existing Edge Functions.
Estimates use verified 2026 pricing and assume typical usage per user. Your real bill depends on activity, storage, and third-party plans.
What breaks when AI tools build this
The failures people actually hit on their first build — the symptom, why it happens, and the exact fix.
Role check in React but no RLS policy in the database
Symptom: This is the most common RBAC security failure. AI generates a React route guard that hides the button or redirects non-admins, but forgets to add matching Supabase RLS policies on the data tables. Any developer with the Supabase anon key (visible in the browser's network tab) can call the Supabase REST API directly, skip the UI entirely, and read or modify data their role should not allow.
Fix: For every role check in the UI, there must be a corresponding RLS policy on the database table. Test by calling the Supabase REST API directly as a member-role user (use the browser DevTools Network tab to grab the auth token) and confirming admin-only data returns an empty result or permission error.
Role update doesn't reflect until user re-logs in
Symptom: Supabase JWT access tokens are issued at login and cache the user's identity for up to 1 hour. If an admin changes another user's role in the database, that user's JWT still carries their old role. Any role check based on the JWT claim (auth.jwt() ->> 'role') continues to use the stale role, so the role change appears to have no immediate effect.
Fix: Call supabase.auth.refreshSession() in the role context provider after a role change is detected (via a Realtime subscription on user_roles) to force a new JWT with the updated claim. Alternatively, use a direct user_roles DB query (not a JWT claim) for role checks so the DB is always the source of truth, at the cost of one extra query per check.
Admin accidentally removes their own admin role
Symptom: The admin user list page typically shows the currently logged-in admin in the same table as everyone else, with the same role dropdown. There is no default protection preventing an admin from selecting 'member' from their own row's dropdown and immediately losing access to the admin panel they are currently viewing.
Fix: In the role update Edge Function, check if user_id === assigned_by and role_id corresponds to a demotion from admin — return a 403 with message 'You cannot change your own role'. In the UI, disable the role dropdown for the row matching the current user's ID and show a tooltip: 'Contact another admin to change your role'.
Lovable generates role names as hardcoded strings in 12 different files
Symptom: When prompted to add role checks throughout the app, Lovable writes if (role === 'admin') in each component, page, and Edge Function individually. If you later rename a role or add a new one, you must find and update every occurrence — usually missing at least one, which silently breaks that access path.
Fix: Prompt Lovable: 'Create a roles constants file at src/config/roles.ts that exports ROLES = { ADMIN: 'admin', EDITOR: 'editor', MEMBER: 'member' } and import ROLES from this file everywhere a role check appears — no inline strings.' Then verify the generated code uses the constants import rather than inline strings.
V0 generates Clerk RBAC when Supabase was intended
Symptom: V0 is trained on many auth patterns and defaults to Clerk's organizationMembership() and role() APIs when a prompt mentions 'roles' without specifying the auth backend. If your project uses Supabase Auth, the Clerk code is incompatible and breaks your existing login flow.
Fix: Always specify the backend explicitly at the start of the prompt: 'Use Supabase Auth and Supabase RLS for authentication and role-based access control — do not use Clerk.' Include this in every role-related prompt, not just the first one.
Best practices
Always enforce roles at the database level with RLS policies — UI guards are for UX, not security
Store role names in a constants file (src/config/roles.ts) and import them everywhere — never write inline role strings that become stale when roles change
Use a service role key in an Edge Function for all role assignment operations — never allow the anon key to modify user_roles
Protect against self-demotion in the role assignment API: an admin should never be able to downgrade their own role from the same UI that manages others
Refresh the user's JWT after a role change via supabase.auth.refreshSession() to avoid a 1-hour window where the old role still appears valid
Index user_roles on user_id — at 10,000+ users, un-indexed role lookups in RLS policies add measurable query latency
Test role enforcement by calling the Supabase REST API directly with a member token — if member data returns admin-only rows, you have a missing RLS policy
Seed the three default roles at migration time so the roles table always has known IDs — hardcoding role IDs in RLS policies breaks if IDs differ between environments
When You Need Custom Development
Three-to-five named roles with Supabase RLS are well within reach of AI tools. Custom development becomes necessary when your access model is more complex:
- You need more than five roles with overlapping and inherited permissions — for example, a content moderation hierarchy where senior-editor inherits all editor permissions plus additional override rights
- You need time-limited role grants — for example, 'grant this user admin access for the next 7 days then revert to editor' with automatic expiry
- You need per-resource permissions where a user can edit only their own department's records, not all records of the same type (row-level access control within a role)
- You need a permissions management UI where non-technical administrators add and remove permissions from roles without any code deployment
RapidDev builds these features for production
Real auth, database, and payments — fixed price, delivered in weeks. You own 100% of the code.
Frequently asked questions
What's the difference between roles and permissions?
A role is a named group (admin, editor, member). A permission is a specific allowed action (create:post, delete:user). In a simple RBAC system, roles imply permissions — being an editor means you can create posts without needing to check an explicit 'create:post' permission. In more complex systems, you have a role_permissions join table that maps roles to specific permissions, giving you fine-grained control over what each role can do.
Can a user have multiple roles at the same time?
With the user_roles schema above, yes — the PRIMARY KEY is (user_id, role_id), so a user can have multiple roles simultaneously. For most SaaS apps, one role per user is simpler and sufficient. Multi-role setups are useful in team tools where someone might be both an admin for their organization and a member of a specific project.
How do I restrict a page so only admins can see it?
Two steps: 1) Add a route guard in your UI — in Next.js middleware.ts, check the user's role before the page renders and redirect to /access-denied if not admin. 2) Add an RLS policy on every Supabase table that page reads from, using get_my_role() = 'admin' in the USING clause. Step 1 handles the UX; step 2 handles the security. Both are required.
Where should role checks live — in the UI or in the database?
Both. Database RLS policies are the security layer — they prevent unauthorized data access regardless of what the UI does. UI role checks are the UX layer — they hide irrelevant UI, redirect unauthorized routes, and make the app feel consistent. If you can only implement one, implement RLS policies first: a user who sees an admin button they can't use is annoying; a user who can read admin data without admin access is a breach.
Can I add roles without writing any SQL?
Not with Supabase — you need to create the roles and user_roles tables and write at least basic RLS policies. However, the SQL in the Data Model section above is a complete, copy-paste schema you can run directly in the Supabase SQL editor without any modifications for a standard three-role setup. Clerk offers a no-SQL roles management UI if you prefer, at $25/mo for the Pro plan.
How do I show different menus for different user types?
Load the current user's role into a React context on sign-in, then conditionally render menu items based on the role. For example: only show the 'Admin' menu item if role === 'admin'. Use the hasPermission(requiredRole) helper from the context rather than inline role comparisons. The menu hiding is a UX convenience — the actual protection is the route guard and RLS policies on the data behind those menu items.
What happens if a user's role is changed while they're logged in?
Their active JWT still carries the old role for up to 1 hour (the default Supabase token lifetime). During that window, JWT-based checks see the old role; DB queries using get_my_role() see the new role immediately. To sync the JWT, call supabase.auth.refreshSession() after the role change — this fetches a new access token with the updated claim. For Supabase with Realtime, you can subscribe to changes on the user_roles table and trigger a refresh automatically.
How many roles should a typical SaaS app have?
Three is the sweet spot for most apps: admin (full access), editor or manager (content/record management without user management), and member or viewer (read-only). Adding more roles increases complexity — each new role needs corresponding RLS policies on every table. Start with three, ship, and add roles only when a specific user type is consistently blocked from what they need or has access to more than they should.
Need this feature production-ready?
RapidDev builds custom roles into real apps — auth, database, payments — at $13K–$25K.
Book a free consultation30-min call. No commitment.