Role-Based Access Control in a Lovable App: The Real Implementation
Lovable ships auth but not RBAC. The role schema, custom access token hook, RLS policies and server-side UI checks that actually hold up in production.
Lovable will generate a working auth flow in a single prompt. Email and password, magic links, the protected route wrapper, the little avatar dropdown in the header. What it will not generate is role-based access control, and the gap between "users can log in" and "admins can see the admin panel but members cannot" is where almost every Lovable app we have audited quietly falls apart.
The reason is structural. Lovable wires Supabase auth through auth.users and calls it done. Supabase itself ships no opinion about roles. There is no role column on auth.users, no built-in permissions table, no middleware that knows the difference between a paying customer and a support agent. RBAC is a thing you build on top, and it has to be built correctly in three places at once: the database schema, the Row Level Security policies, and the UI render layer. Miss any of the three and you end up with an app where either admins cannot do their job or, worse, a member can open DevTools and promote themselves.
This piece walks through the implementation we ship for Lovable clients who need multi-role access. The schema is a user_roles table plus a Postgres enum. The enforcement is RLS policies that read a custom JWT claim injected by an access token hook, so the database is not running a subquery against user_roles on every request. The UI layer is a server component check that never trusts the client. And the thing nobody in the SERP top ten will tell you, role changes do not take effect until the JWT refreshes, so you have to force it.
Why Lovable's Default Auth Is Not RBAC
The Lovable starter, when you ask for "auth with Supabase," produces a profiles table with id, email, and usually full_name. Every signed-in user is functionally identical. If you then ask Lovable to add an admin panel, what it typically generates is a client-side check against a hardcoded email address or, in slightly better attempts, a profiles.is_admin boolean. Both are broken.
The hardcoded email check is broken because it is client-side. A user opens the React DevTools, finds the if (user.email === 'you@yourcompany.com') branch, and can either edit their local state to render the admin component or, more simply, call the underlying Supabase queries directly from the browser console. The component tree does not enforce anything. It only decides what to paint.
The is_admin boolean is broken in a different way. It scales to exactly two roles, admin and everyone else. The moment you need a third role, viewer or editor or billing_manager, you are either adding another boolean column (which creates an invalid state where someone can be both is_admin and is_viewer at once) or you are doing a schema migration. You also still have the RLS problem, because none of Lovable's generated policies reference the boolean. The policies say auth.uid() = user_id, which is fine for "a user can read their own row" and useless for "an admin can read any row."
Proper RBAC replaces both of these with a schema that can express any number of roles cleanly, a policy layer that enforces those roles at the database, and a UI layer that asks the server, not the client, what role the current user has.
The Schema: Enum Plus user_roles Table
There are two dominant schema patterns. The first is a role column on profiles, typed as a Postgres enum or a varchar. The second is a separate user_roles table with a foreign key back to auth.users. We ship the second pattern in every production build, and the reasoning is worth walking through.
A column on profiles forces a one-to-one relationship. A user has exactly one role. That works until the first time a client says "support agents should also have the billing role during end-of-month reconciliation." Now you either nest roles into a comma-separated string (a decision that will haunt the codebase for years) or you add a second column, and you are already in the mess the dedicated table was designed to avoid. A user_roles table makes the assignment many-to-many by default. One user, many roles. Assigning a new role is an insert. Revoking one is a delete. No schema change, no migration.
The role itself should be a Postgres enum, not a varchar. An enum refuses inserts that do not match the defined set. A varchar accepts "adminn" or "Admin" or "ADMIN" and those typos will ship to production because nothing in the code path checks. The trade-off is that adding a new role requires ALTER TYPE app_role ADD VALUE 'new_role', which is a migration. In practice this is the correct trade. You will add new roles maybe twice a year. You will fat-finger a string comparison roughly weekly.
Here is the schema we ship.
create type public.app_role as enum ('admin', 'member', 'viewer');
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,
created_at timestamptz default now(),
unique (user_id, role)
);
alter table public.user_roles enable row level security;
The unique (user_id, role) constraint prevents the same role being assigned twice to the same user, which would otherwise be a silent data quality issue. The on delete cascade on the FK means deleting a user cleans up their role assignments, which Supabase will otherwise leave orphaned.
Next, a has_role function. This is a security definer function, which runs with the privileges of its owner rather than the calling user, so it can read user_roles even when the caller's RLS policies would block the read. Without this, RLS policies that reference user_roles create recursive permission checks and either deadlock or leak.
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
);
$$;
Two details matter here. stable tells Postgres the function returns the same result within a single statement, which lets the planner cache the result per row rather than re-executing per policy check. set search_path = public is a hardening step that prevents a caller from shadowing user_roles with a local schema and spoofing the lookup. Both are routinely missing from the tutorials that rank on page one.
The Pattern Everyone Misses: The Custom Access Token Hook
The naive RLS policy at this point would call has_role(auth.uid(), 'admin') directly. That works, and it is what most Supabase RBAC tutorials stop at. It is also a performance footgun. Every row that policy evaluates runs the has_role function, which runs a subquery against user_roles. On a table with ten thousand rows and a policy that reads "admins can select all rows," you are doing ten thousand lookups against user_roles for a single query. The planner can optimise some of this. It cannot optimise all of it.
The fix is to stop asking the database for the role on every request and instead embed the role into the JWT at session creation. Supabase ships this as the custom access token hook. It is a Postgres function you register under Authentication, Hooks, in the Supabase dashboard. On every token issuance, Supabase calls the function, passes the draft JWT claims, and writes whatever you return back into the token the client receives.
create or replace function public.custom_access_token_hook(event jsonb)
returns jsonb
language plpgsql
stable
as $$
declare
claims jsonb;
user_role text;
begin
select role::text into user_role
from public.user_roles
where user_id = (event->>'user_id')::uuid
order by
case role
when 'admin' then 1
when 'member' then 2
when 'viewer' then 3
end
limit 1;
claims := event->'claims';
if user_role is not null then
claims := jsonb_set(claims, '{user_role}', to_jsonb(user_role));
else
claims := jsonb_set(claims, '{user_role}', '"viewer"');
end if;
event := jsonb_set(event, '{claims}', claims);
return event;
end;
$$;
grant execute on function public.custom_access_token_hook to supabase_auth_admin;
revoke execute on function public.custom_access_token_hook from authenticated, anon, public;
The order-by clause picks the highest-privilege role when a user has multiple. If a user is both admin and member, the JWT carries admin. The fallback to viewer when the user has no assigned role is a defensive default, you can omit it if you want unauthenticated state to throw at the policy layer instead.
Grant and revoke matter. The function runs during auth token issuance, which is performed by the supabase_auth_admin role. If authenticated or anon can execute it, you have leaked a way for the client to invoke the hook manually with arbitrary claims.
With the hook registered, every authenticated request now carries the role inside auth.jwt(). RLS policies read the claim directly, no table lookup required.
create policy "admins can read all projects"
on public.projects
for select
to authenticated
using ((auth.jwt() ->> 'user_role') = 'admin');
create policy "members can read their own projects"
on public.projects
for select
to authenticated
using (
(auth.jwt() ->> 'user_role') in ('member', 'admin')
and owner_id = auth.uid()
);
Every policy evaluation is now a string comparison against a value already present in the session context. No subquery, no function call, no round trip to user_roles. On a large table this is the difference between a policy that adds five milliseconds and one that adds five hundred.
The Server-Side UI Check
RLS handles the database. It does not handle the UI. If you render an admin panel to a member and rely on RLS to prevent the member from actually doing anything, you have built a confusing product. The panel loads, buttons are visible, every click returns a permission error. The correct pattern is to not render the admin panel at all unless the current user is an admin, and that check has to happen on the server.
Client-side checks are cosmetic. A check that lives in a React component renders the component tree differently, but every piece of data the component requests is still fetched by the browser, and every protected route is still a URL the user can type into the address bar. In a Next.js app with the App Router, the right place to enforce this is the server component, the route handler, or middleware. We use server components because they give the clearest boundary.
// app/admin/layout.tsx
import { createServerClient } from '@supabase/ssr'
import { cookies } from 'next/headers'
import { redirect } from 'next/navigation'
import { jwtDecode } from 'jwt-decode'
export default async function AdminLayout({
children,
}: {
children: React.ReactNode
}) {
const cookieStore = cookies()
const supabase = createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
cookies: {
get: (name) => cookieStore.get(name)?.value,
},
}
)
const {
data: { session },
} = await supabase.auth.getSession()
if (!session) {
redirect('/login')
}
const decoded = jwtDecode<{ user_role?: string }>(session.access_token)
if (decoded.user_role !== 'admin') {
redirect('/')
}
return <>{children}</>
}
The layout runs on the server. The redirect happens before any admin markup is serialised. The client never sees the component tree. The JWT decode is a pure string parse, no network call, because the role is already in the token. If the token is forged or tampered, Supabase will reject the session at the next API call, so the decoded claim is safe to trust for routing.
For finer-grained in-page gating, a tiny helper reads the same claim inside any server component.
// lib/auth/role.ts
import { cookies } from 'next/headers'
import { createServerClient } from '@supabase/ssr'
import { jwtDecode } from 'jwt-decode'
export async function getCurrentRole(): Promise<'admin' | 'member' | 'viewer' | null> {
const cookieStore = cookies()
const supabase = createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{ cookies: { get: (n) => cookieStore.get(n)?.value } }
)
const { data: { session } } = await supabase.auth.getSession()
if (!session) return null
const { user_role } = jwtDecode<{ user_role?: string }>(session.access_token)
return (user_role as any) ?? null
}
Call it inside any server component or route handler. Never port this logic to a client component. The moment a role decision lives in client JavaScript, a determined user can rewrite it.
The Role-Change Problem Nobody Writes About
The custom access token hook has one cost. The role lives in the JWT, and the JWT is immutable until it refreshes. Supabase access tokens last one hour by default. If an admin promotes a viewer to member at 2:00pm, that user is still a viewer everywhere in the system until roughly 3:00pm, because their existing JWT still carries user_role: viewer and every RLS policy and server check reads the JWT.
There are three ways to handle this and you should implement at least one.
The simplest is to accept the lag. For a role change that grants new capabilities, waiting up to an hour is usually fine, the user can sign out and back in if they want the new role immediately. For a role change that revokes capabilities, this is not acceptable, a user who was just demoted is still admin for an hour.
The second is to force a session refresh after every role mutation. From a server action that updates user_roles, call supabase.auth.admin.signOut(userId) using the service role key. The next request from that user's client will fail the token refresh, the Supabase JS client catches this, and the user is redirected to log in again. New JWT, new claim, new permissions. This is the pattern we ship by default because it is the only one that closes the revocation window.
The third is to supplement the JWT check with a live has_role call on sensitive routes. On the admin panel's server layout, after reading the claim from the JWT, also run a supabase.rpc('has_role', { _user_id, _role: 'admin' }) query. If the live check disagrees with the JWT, trust the live check and redirect. This doubles the latency of those routes but gives you zero-lag revocation without forced sign-outs. Reserve it for the handful of routes where the worst case of an hour-stale role is unacceptable.
Whichever you pick, document it. The number one support ticket we see on RBAC implementations is "I made this user an admin twenty minutes ago and they still cannot see the admin panel." It is always the JWT cache. Always.
A related trap worth naming. The Supabase JS client exposes supabase.auth.refreshSession(), and a fair number of tutorials recommend calling it from the client after a role mutation. That does refresh the access token, but only for the user initiating the mutation. If an admin is changing someone else's role, the refresh on the admin's client does nothing for the target user, because the target user is on a different device with a different session. This is why the server-side auth.admin.signOut(userId) pattern is the one that actually works, it invalidates the target's refresh token from the server side rather than trying to coordinate two browsers that do not know about each other.
What Lovable Ships Versus What You Ship
Lovable will generate a profiles table, an is_admin column, and a client-side check against it. That is not RBAC, it is a flag, and the flag is trivially bypassed. The implementation in this piece is the version that holds up under a real user base with real billing tiers and a real support team. A role enum, a many-to-many user_roles table, a has_role function hardened with security definer and a pinned search path, a custom access token hook that promotes the role into the JWT, RLS policies that read the claim instead of querying a table, a server-side Next.js role check, and a plan for what happens when a role changes before the JWT does.
Every piece above is necessary. Skip the enum and you get typo bugs. Skip the access token hook and every policy adds a subquery. Skip the server-side UI check and your admin panel is decoration. Skip the refresh story and you ship a revocation hole. The pieces are not interchangeable.
-> If your Lovable app is running on a profiles.is_admin boolean and you need it to run on this, WitsCode ships the migration, the hook, the policies, and the Next.js auth layer as a fixed-scope engagement. We are the last-mile developers for vibe coders, and this is the exact mile we do best.
Get weekly field notes.
Practical writing on shipping products, straight to your inbox. No spam.
Need help with this?
MVP Development
We design and build web apps, MVPs, and SaaS products. Talk to us about what you are working on.
Talk to usWant to discuss vibe coders for your business?
Start a project and we'll talk through where you are, what's working, and the highest-leverage moves for the next 90 days.