Abbi Labs

Guides

Can a user make themselves admin in your Supabase app? Where to store roles safely

By Pritesh Ghodge, Senior Solutions Engineer (cybersecurity) · Last updated 10 November 2026

Scan your app free — see what a signed-out stranger can reach.

Abbi Labs checks your live site from the outside: secret keys in the JavaScript, Supabase tables readable with your public key, role-looking columns and admin-sounding RPC functions exposed to the anon role, common admin URLs, exposed .env and .git files, and security headers. Read-only. No sign-up for the quick check.

No sign-up. Read-only public-resource and protocol metadata checks. No model prompts or tool execution. Scan only sites you own or are allowed to test.

Run on every deploy or use from Cursor / Claude Code via MCP →

Weekly AI-app security checklist

One short email a week: a practical check for apps built with Supabase, Next.js and AI tools (RLS, leaked keys, headers and more). Unsubscribe anytime.

Most AI-built apps that have an admin page get there the same way: add a role or is_admin column to profiles, hide the admin link unless it's true, and add an RLS policy so users can edit their own profile. That last step is where it goes wrong. Here's why, how to check your own project, and where roles should live instead.

The trap: RLS checks the row, not the column

A typical generated policy looks like this:

create policy "users update own profile" on public.profiles
  for update to authenticated
  using ((select auth.uid()) = id)
  with check ((select auth.uid()) = id);

It correctly stops user B editing user A's profile. But it says nothing about which columns A may change. If profiles has a role or is_admin column, any signed-in user can run this from the browser console of your own app:

await supabase.from('profiles')
  .update({ role: 'admin' })
  .eq('id', (await supabase.auth.getUser()).data.user.id)

The row is theirs, so the policy passes. If your other policies or your admin page trust profiles.role, they're now an admin. With open sign-ups, "any signed-in user" means anyone on the internet.

Same idea, different place: user_metadata (raw_user_meta_data). A signed-in user can change it with supabase.auth.updateUser({ data: { role: 'admin' } }), so a policy that reads the role from auth.jwt() -> 'user_metadata' is trusting a value the user controls.

Check your project in two minutes

1. Find privilege-looking columns and who can update them (run in the Supabase SQL editor):

select c.table_name, c.column_name,
       has_column_privilege('authenticated', format('public.%I', c.table_name), c.column_name, 'UPDATE') as authenticated_can_update,
       has_column_privilege('anon', format('public.%I', c.table_name), c.column_name, 'UPDATE') as anon_can_update
from information_schema.columns c
where c.table_schema = 'public'
  and c.column_name in ('role','roles','is_admin','admin','is_staff','is_superuser',
                        'permissions','user_role','account_type','plan','access_level')
order by 1, 2;

A true there means the grant allows it, and then only your UPDATE policies stand in the way. If the table has an own-row UPDATE policy, assume users can change that column.

2. Look for policies that read roles from user_metadata:

select schemaname, tablename, policyname, qual, with_check
from pg_policies
where coalesce(qual,'') || coalesce(with_check,'') ilike '%user_metadata%';

3. Paste your policies into the free Supabase RLS policy checker to spot open UPDATE/INSERT policies and with check (true) (one read-only query, analysed in your browser).

4. The real test, on a project you own: sign up as a normal user, open DevTools on your app, and run the update({ role: 'admin' }) snippet above. You want an error or zero rows changed.

Fix option 1: let users update only the columns they should

Postgres grants can be per column. Keep your own-row policy, but take away UPDATE on the privilege column:

revoke update on public.profiles from anon, authenticated;
grant update (display_name, avatar_url, bio) on public.profiles to authenticated;

List every column users legitimately edit. Anything not listed (like role) can then only be changed by server code using the secret key, or by you in the dashboard. Check your app's profile form still saves after this.

Fix option 2: move roles to their own table

create table public.user_roles (
  user_id uuid primary key references auth.users on delete cascade,
  role text not null default 'user'
);
alter table public.user_roles enable row level security;
-- users may read their own role; no insert/update/delete policies at all
create policy "read own role" on public.user_roles
  for select to authenticated using ((select auth.uid()) = user_id);

create or replace function public.is_admin()
returns boolean language sql stable security definer set search_path = ''
as $$ select exists (select 1 from public.user_roles
  where user_id = (select auth.uid()) and role = 'admin') $$;

Then use (select public.is_admin()) in admin policies. With no write policies on user_roles, only server code with the secret key (or you, in SQL) can grant admin.

Fix option 3: app_metadata, not user_metadata

If you'd rather keep roles on the user, put them in app_metadata. Users can't change it from the client; only server code with the secret key (the Auth admin API) can. Policies can read it with auth.jwt() -> 'app_metadata' ->> 'role'. Note the JWT only picks up the change when the user's token refreshes. Supabase's custom access token hook is another way to put a role claim in the token from a table like user_roles.

Don't forget admin functions (RPC)

AI builders often add helper functions like make_admin, set_user_role or delete_user. Postgres lets PUBLIC execute new functions by default, and Supabase exposes functions in your API schema at /rest/v1/rpc/<name>. If one is security definer and doesn't check the caller, anyone with your public key may be able to call it.

revoke execute on function public.make_admin(uuid) from public, anon, authenticated;
-- or, if signed-in admins must call it, keep authenticated and check inside:
-- if not public.is_admin() then raise exception 'not allowed'; end if;

Same goes for admin pages: hiding the link isn't protection. The server route (and the data behind it) has to check the role too. More on functions that bypass RLS: Security Definer views and functions.

A prompt for your AI builder

List every column in my public schema that controls roles or permissions
(role, is_admin, plan, etc.) and every RLS policy or function that reads them.
For each, tell me whether a normal signed-in user can change it (own-row
UPDATE policy, table-wide UPDATE grant, or user_metadata). Propose a fix that
uses column-level grants or a separate user_roles table with no write
policies, and revoke EXECUTE on admin functions from anon and authenticated.
Show me the SQL before applying it.

What an outside scan can and can't tell you

From the outside, Abbi Labs can see what the anon (signed-out) role is allowed to do according to Supabase's public API description, which tables return rows to your public key, which RPC names your JavaScript calls, and whether common admin URLs answer signed-out visitors. It can't sign in as your users, so it can't prove a signed-in user can promote themselves. That's what the DevTools test above is for. Use both.

Keep it that way

Abbi Labs scans your live app for leaked keys, Supabase tables readable with your public key, privilege-escalation signals, exposed .env/.git files and security headers. Monitor re-scans weekly and after deploys, and emails you only when something new appears.

Scan your app free

FAQ

Can a user change their own role in Supabase?

Yes, if the role lives somewhere the user can write. The common case is a profiles table with a role or is_admin column and an UPDATE policy like using ((select auth.uid()) = id). RLS checks which row, not which columns, so that policy lets the user set their own role to admin. user_metadata is the other common case: a signed-in user can change it with supabase.auth.updateUser.

Where should I store user roles in Supabase?

Somewhere users cannot write: a separate roles table with no INSERT/UPDATE/DELETE policies for anon or authenticated, or app_metadata (which only server code with the secret key can change). Read the role in policies through that table or the JWT's app_metadata claim, never from user_metadata.

Is it safe to keep a role column on my profiles table?

Only if users can't update that column. Revoke table-wide UPDATE from anon and authenticated, then grant UPDATE on just the columns users should edit (for example display_name and avatar_url). A BEFORE UPDATE trigger that rejects role changes from non-admins also works. Test it as a normal signed-in user.

Are Supabase database functions callable by anyone?

New Postgres functions are executable by PUBLIC by default, and Supabase exposes functions in exposed schemas at /rest/v1/rpc/<name>. If a function like make_admin or set_role is SECURITY DEFINER and doesn't check who is calling it, anyone with your public key may be able to call it. Revoke EXECUTE from public, anon and authenticated, and check the caller inside the function.

Does Abbi Labs check for this?

Partly, from the outside and read-only. The scan flags tables whose column names look like privilege columns (role, is_admin and similar) when Supabase's public API description lists write methods for the anon role, privilege columns readable when signed out, RPC functions with admin-sounding names listed for anon or referenced in your JavaScript, and a short list of common admin URLs that return admin-looking content when signed out. It never writes to your database or calls those functions, so it can't prove a signed-in user can promote themselves. Use the two-account test below and the free policy checker for that.

Related guides

Weekly AI-app security checklist

One short email a week: a practical check for apps built with Supabase, Next.js and AI tools (RLS, leaked keys, headers and more). Unsubscribe anytime.