Adding admin roles to a Lovable + Supabase app (without trusting user_metadata)
Guide · October 16, 2026
Do not store an admin role in user_metadata. The signed-in user can change that field themselves with supabase.auth.updateUser(). Put the role in a user_roles table, add a user_role claim to the JWT with a Custom Access Token Hook, and write RLS policies that read that claim.
Hiding the admin link in Lovable is UI only. The Data API is still there.
Sources: Custom Claims and RBAC, Custom Access Token Hook, Auth Hooks, Row Level Security, Users, updateUser, getClaims.
Why user_metadata is not a role
Lovable will happily put role: "admin" on the user object. It looks tidy. It is also writable by the person you are trying to keep out. The Users guide says not to use user_metadata in RLS or any authorization logic. In supabase-js the call is:
const { data, error } = await supabase.auth.updateUser({
data: { role: "admin" }
});
That data object writes user_metadata. Anyone with a session can run it from the browser console. Your navbar can hide /admin. The table is still readable if the policy trusts that field.
The RLS docs draw a line between two JWT fields:
raw_user_meta_data/user_metadata: the authenticated user can update this. The RLS page still namessupabase.auth.update(). The current JS method isupdateUser({ data }).raw_app_meta_data/app_metadata: the user cannot update this, so it is a reasonable place for authorization data.
app_metadata is changed through the Auth Admin API, supabase.auth.admin.updateUserById(). The docs say that call must run on a server. Never put the service_role key (or the newer secret key, sb_secret_...) in the Lovable frontend.
So: user_metadata is out. app_metadata is the safer metadata field if you go that route. The pattern Supabase documents for RBAC is still a table plus a hook.
The documented pattern: user_roles plus a hook
The Custom Claims and RBAC guide uses a Custom Access Token Auth Hook. The hook runs before a token is issued and can add claims to the JWT. The example adds a user_role claim from a user_roles table.
create type public.app_role as enum ('admin', 'moderator');
create table public.user_roles (
id bigint generated by default as identity primary key,
user_id uuid references auth.users on delete cascade not null,
role app_role not null,
unique (user_id, role)
);
comment on table public.user_roles is 'Application roles for each user.';
The full guide also adds role_permissions if you want named permissions like channels.delete. For a Lovable admin page, admin vs everyone else is usually enough. Assign a role as the table owner, or with a secret / service_role client. Do not grant insert on user_roles to authenticated:
insert into public.user_roles (user_id, role)
values ('USER_UUID', 'admin');
Then the hook. This is the PL/pgSQL version from the same guide, including the grants it shows for supabase_auth_admin:
create or replace function public.custom_access_token_hook(event jsonb)
returns jsonb
language plpgsql
stable
as $$
declare
claims jsonb;
user_role public.app_role;
begin
select role into user_role from public.user_roles
where user_id = (event->>'user_id')::uuid;
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}', 'null');
end if;
event := jsonb_set(event, '{claims}', claims);
return event;
end;
$$;
grant usage on schema public to supabase_auth_admin;
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;
grant all
on table public.user_roles
to supabase_auth_admin;
revoke all
on table public.user_roles
from authenticated, anon, public;
create policy "Allow auth admin to read user roles" ON public.user_roles
as permissive for select
to supabase_auth_admin
using (true);
supabase_auth_admin is the role Auth uses to talk to your database. It does not have public schema rights by default, so you grant usage, execute on the hook, and read on user_roles, then revoke those from authenticated, anon, and public so the Data API cannot call the hook or rewrite roles.
Enable the hook in the dashboard: Authentication → Hooks, then pick the Postgres function. The RBAC page still labels this screen Hooks (Beta). The Auth Hooks page uses the same path without Beta. Locally you set it in config.toml instead. The hook only edits the access token, not the rest of the auth response.
Reading the role in the frontend
Do not query user_roles from the browser to decide whether to show /admin. You revoked that table from authenticated on purpose. Read the claim off the token. supabase-js documents getClaims() for this. It verifies the JWT (against JWKS, or the Auth server if you are not on asymmetric keys) and returns the claims:
const { data, error } = await supabase.auth.getClaims();
const userRole = data?.claims?.user_role;
The RBAC guide still shows jwt-decode on session.access_token, which is the same idea without that verification step:
import { jwtDecode } from "jwt-decode";
const jwt = jwtDecode(session.access_token);
const userRole = jwt.user_role;
The claim is a snapshot. The RLS docs say a JWT is not always up to date, and auth.jwt() will not see a change until the token is refreshed. The hook runs each time a new JWT is created, including token_refresh. After you change user_roles, that user needs to sign in again or call refreshSession() (the updateUserById examples do this after a server-side metadata change). Use the claim to show or hide the admin route. Then go write a policy. The UI is not the lock.
The part that actually protects data
Hiding a React route, a Lovable page, or a navbar link is not security. The anon key is in the client on purpose. Anyone can call /rest/v1/your_table with it. If RLS is off, or the policy is using (true), or the policy trusts user_metadata, the rows come back. That is the same hole as a public database.
RLS is the lock. Two documented ways to check the role:
- Read the claim in the policy:
(auth.jwt() ->> 'user_role') = 'admin'. - A
security definerhelper. The RBAC guide'sauthorize()reads the claim once, then checks arole_permissionstable. The RLS guide also shows ahas_good_role()style function that looks up a roles table.
For a Lovable admin table, the claim check is enough:
alter table public.admin_notes enable row level security;
create policy "Admins can select admin_notes"
on public.admin_notes
for select
to authenticated
using ( (select auth.jwt() ->> 'user_role') = 'admin' );
grant select on table public.admin_notes to authenticated;
Wrap auth.jwt() in (select ...) so Postgres caches it per statement, as the RLS guide recommends. Add matching write policies if admins insert or update this table. A missing GRANT is a hard 42501. A USING miss on UPDATE still looks like a successful save; that is the silent update shape, so chain .select() when you debug writes. If you want named permissions, copy authorize() from the RBAC guide: security definer with search_path = '', called as (SELECT authorize('channels.delete')).
Do not do this:
- Store
roleinuser_metadata(or aprofiles.rolecolumn the user can update) and check it in RLS. - Hide
/adminin the navbar and leave the table with RLS off, or withusing (true). - Grant
insert/updateonuser_rolestoauthenticated. - Put the
service_roleor secret key in Lovable frontend env so the client can "just bypass RLS for admins."
Policies run for the authenticated role. They do not run for service_role. The secret key (and the legacy service_role JWT) bypasses RLS. An Edge Function that uses a privileged client must check the caller itself. The Securing Edge Functions docs put verified claims on ctx.jwtClaims when you wrap the handler with withSupabase({ auth: 'user' }). Prefer ctx.supabase (RLS applies) over ctx.supabaseAdmin, and if you do use the admin client, check jwtClaims.user_role before you touch admin rows.
Check your app in 5 minutes
- Where is the role stored? If it is
user.user_metadata.role, or aprofiles.rolecolumn the owner can update, it is not a role. It is a suggestion. - Can a non-admin read the admin table? From a private window, hit
/rest/v1/<table>?select=*&limit=5with the anon key, same as the public database check. Then retry as a signed-in non-admin. Rows here mean the policy is not the lock. - Can the signed-in user change their own metadata? In the console:
await supabase.auth.updateUser({ data: { role: "admin" } }). If your app suddenly treats them as admin, that was the hole. - After you promote someone in
user_roles, do they still look like a member? Ask them to sign in again or callrefreshSession(). - Does an Edge Function use the secret /
service_roleclient? RLS will not save you. Check the caller's claim in the function.
Questions
Can I store an admin role in user_metadata?
No. Supabase documents user_metadata as editable by the signed-in user with no extra checks. updateUser({ data: { role: "admin" } }) writes that field. Do not use it in RLS or any authorization logic.
Is hiding the admin page in Lovable enough?
No. A hidden navbar link or a wrapped React route is UI only. The anon key is in the client on purpose. If RLS does not check the role, the admin table is still readable through the Data API.
Can a user update their own app_metadata?
No. The RLS docs say raw_app_meta_data cannot be updated by the user. Changes go through updateUserById on a server, with the secret or service_role key kept off the client.
Why is someone still a member after I made them admin?
The hook writes user_role onto the JWT. That claim is a snapshot. auth.jwt() will not see the change until the token is refreshed. Have them sign in again or call refreshSession().
Does an Edge Function with the service role key still respect RLS?
No. service_role bypasses RLS. If the function uses a privileged client, check the caller's JWT claim in the function. The Securing Edge Functions docs expose verified claims as ctx.jwtClaims when you use withSupabase({ auth: 'user' }).
Empty array from a table you know has data, or a save that reverts: RLS silent updates. Stranger can read rows with the anon key and no login: is my database public?
If you'd rather have us set this up or check it, tell us what's broken. We reproduce it in your repo and send a PR. You can also run a free scan for leaked keys on the public pages.
Tell us what's broken