FixLoop

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:

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:

  1. Read the claim in the policy: (auth.jwt() ->> 'user_role') = 'admin'.
  2. A security definer helper. The RBAC guide's authorize() reads the claim once, then checks a role_permissions table. The RLS guide also shows a has_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:

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

  1. Where is the role stored? If it is user.user_metadata.role, or a profiles.role column the owner can update, it is not a role. It is a suggestion.
  2. Can a non-admin read the admin table? From a private window, hit /rest/v1/<table>?select=*&limit=5 with 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.
  3. 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.
  4. After you promote someone in user_roles, do they still look like a member? Ask them to sign in again or call refreshSession().
  5. Does an Edge Function use the secret / service_role client? 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