DEV Community

Cover image for Why Cursor Stores Supabase Admin Roles in user_metadata
Charles Kern
Charles Kern

Posted on Originally published at safeweave.dev

Why Cursor Stores Supabase Admin Roles in user_metadata

TL;DR

  • AI editors keep putting a Supabase user's role in user_metadata, then gate admin RLS policies and pages on it.
  • user_metadata is writable by the user. One supabase.auth.updateUser({ data: { role: 'admin' } }) call from the browser console makes anyone an admin.
  • Put roles in app_metadata (server-only) or a roles table with no client write policy, and check that instead.

Ask Cursor or Claude Code to add an admin dashboard to a Supabase app and you will often get something tidy. A role field on sign-up, an RLS policy so admins can read every order, a middleware check that sends non-admins away from /admin. It works on the first run.

Then open the browser console on a normal, non-admin account and run one line. You are now an admin.

No exploit, no leaked key. The role was stored in the one place Supabase explicitly lets users edit.

The Vulnerable Code

The bug is reading authorization from user_metadata, a field the signed-in user controls. Here is the pattern as AI editors usually write it, in three places at once.

Sign-up stores the role on the user:

// signup.js
await supabase.auth.signUp({
  email,
  password,
  options: { data: { full_name: name, role: 'user' } },
});
Enter fullscreen mode Exit fullscreen mode

An RLS policy trusts it:

-- CWE-269: privilege decided by a value the user can set
create policy "Admins can read all orders"
on public.orders
for select
to authenticated
using ( (select auth.jwt()) -> 'user_metadata' ->> 'role' = 'admin' );
Enter fullscreen mode Exit fullscreen mode

And the app trusts it too:

// middleware.js
const { data: { user } } = await supabase.auth.getUser();
if (user?.user_metadata?.role !== 'admin') {
  return NextResponse.redirect(new URL('/', request.url));
}
Enter fullscreen mode Exit fullscreen mode

Here is the whole attack, typed into devtools on any signed-in account:

await supabase.auth.updateUser({ data: { role: 'admin' } });
await supabase.auth.refreshSession();
Enter fullscreen mode Exit fullscreen mode

The new token now carries role: admin. The policy passes, the middleware passes, and every order in the table comes back. An attacker does not even need the update call. They can pass role: 'admin' straight into signUp and arrive as an admin.

Supabase's own docs are blunt about this. The RLS guide says raw_user_meta_data "can be updated by the authenticated user" and that a policy relying on the user_metadata claim "can create security issues in your application." Supabase's database linter has a dedicated check for it, 0015 rls_references_user_metadata, at level ERROR. Its rationale: user metadata "is designed to be manipulated by the user themselves" and can be changed "to any value (there is no validation)."

The Same Bug, One Step Removed

Copying the role into a profiles table at sign-up does not fix it. This trigger is a common AI "improvement":

insert into public.profiles (id, full_name, role)
values (
  new.id,
  new.raw_user_meta_data ->> 'full_name',
  coalesce(new.raw_user_meta_data ->> 'role', 'user')
);
Enter fullscreen mode Exit fullscreen mode

The role still originates from data the user sent at sign-up. And if profiles also has the usual "users can update their own profile" policy, a user can update profiles set role = 'admin' on their own row, because RLS checks which row you touch, not which columns.

Why This Keeps Happening

AI editors put the role in user_metadata because it is the only user field the browser client can write. The code runs with the public anon key, so updateUser and signUp({ options: { data } }) are the tools in reach. Writing to app_metadata needs the admin API and the service_role key, which belongs on a server the AI may not have set up yet.

The tutorials point the same way. Sign-up examples store full_name and avatar_url in options.data, and a role field looks like one more profile attribute. Both metadata objects show up side by side in auth.jwt(), so a policy reading user_metadata looks exactly as legitimate as one reading app_metadata. Nothing errors. Nothing warns in the editor. The admin page just works, for everyone who asks.

The Fix

Store roles where only your server can write them, and check them there. You have two good options.

Option 1: app_metadata. Users cannot update it. Set it from a server route or Edge Function with the service_role key, read from a non-public environment variable:

// server only. Never import this file in client code.
const supabaseAdmin = createClient(
  process.env.SUPABASE_URL,
  process.env.SUPABASE_SERVICE_ROLE_KEY
);

await supabaseAdmin.auth.admin.updateUserById(userId, {
  app_metadata: { role: 'admin' },
});
Enter fullscreen mode Exit fullscreen mode

Then point the policy at app_metadata:

create policy "Admins can read all orders"
on public.orders
for select
to authenticated
using ( (select auth.jwt()) -> 'app_metadata' ->> 'role' = 'admin' );
Enter fullscreen mode Exit fullscreen mode

One trade-off from Supabase's docs: a change to app_metadata "will not be reflected using auth.jwt() until the user's JWT is refreshed." Demoting an admin takes effect when their token refreshes, not instantly.

Option 2: a roles table clients cannot write. This takes effect immediately and keeps roles out of the token:

create table public.user_roles (
  user_id uuid primary key references auth.users on delete cascade,
  role text not null check (role in ('user', 'admin'))
);
alter table public.user_roles enable row level security;

create policy "Users can read their own role"
on public.user_roles
for select
to authenticated
using ( (select auth.uid()) = user_id );
-- No insert, update or delete policies: only service_role or SQL can change roles.

create 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'
  );
$$;

create policy "Admins can read all orders"
on public.orders
for select
to authenticated
using ( (select public.is_admin()) );
Enter fullscreen mode Exit fullscreen mode

If you keep a role column on profiles, take it out of what users can update:

revoke update on public.profiles from authenticated;
grant update (full_name, avatar_url) on public.profiles to authenticated;
Enter fullscreen mode Exit fullscreen mode

Drop role from the sign-up options.data and from any trigger that copies metadata into a role column. In middleware, read the role from app_metadata or ask the database, never from user_metadata.

To test it, sign up a fresh account with options: { data: { role: 'admin' } }, then use that session to query an admin-only table. You should get zero rows back. Then grep for the pattern:

grep -rn "user_metadata" src/ app/ supabase/migrations/
Enter fullscreen mode Exit fullscreen mode

Every hit that makes a yes/no access decision is a bug. In the Supabase dashboard, the Security Advisor runs the 0015 lint across all your policies.

FAQ

Q: Can Supabase users edit their own user_metadata?
A: Yes. Any signed-in user can change it with supabase.auth.updateUser({ data: { ... } }), and they can set it at sign-up through options.data. Supabase's docs say it should not be used for security decisions.

Q: What is the difference between user_metadata and app_metadata in Supabase?
A: user_metadata is profile data the user can write. app_metadata cannot be updated by the user and is set through the admin API with the service_role key, which makes it the right place for roles, plans and team membership.

Q: Is checking user_metadata in Next.js middleware safe if the page also checks it?
A: No. Both checks read the same user-controlled value, so both pass for an attacker. Move the role to app_metadata or a protected table and check it on the server and in RLS.

I've been running SafeWeave for this. Its repo checks include a rule for roles read from user metadata (supabase-role-from-user-metadata), and the Supabase integration pulls in Supabase's own security advisors every day, so new policy problems show up without me remembering to look. Those checks are on the Cloud plans. Even without a scanner, the grep and the fresh-account test above catch most of this. The important thing is finding it before someone else opens the console.

Top comments (0)