LEAKCADE

Anyone can make themselves admin: Supabase user_metadata

Updated 4 min read

Supabase stores two kinds of metadata on every user. Your users can rewrite Supabase user_metadata themselves, straight from the browser. If your app reads a role, an admin flag or a paid plan from there, any user can give themselves admin rights or Pro for free.

QUICK CHECKLIST

  • No role, plan or org id in user_metadata
  • No RLS policy reading auth.jwt() -> 'user_metadata'
  • Signup triggers insert a fixed default role
  • Roles set only from server code (app_metadata or a roles table)
  • Security Advisor shows no user_metadata warning

What the bug looks like

An admin route that checks the role like this looks reasonable:

const { data: { user } } = await supabase.auth.getUser()
if (user?.user_metadata?.role !== "admin") {
  return new Response("Forbidden", { status: 403 })
}

Any signed-in user can change that field. With the Supabase client (or a direct call to the Auth API with their own token), it takes one line:

await supabase.auth.updateUser({ data: { role: "admin" } })
How a user makes themselves adminA normal user calls updateUser to set role admin, their next token carries the role, and a policy that reads user_metadata lets them in.USERSigned in as anormal accountUPDATEUSERdata: { role:"admin" }JWTuser_metadata.role= adminADMINCheck passes,admin data opensUSERSigned in as a normal accountUPDATEUSERdata: { role: "admin" }JWTuser_metadata.role = adminADMINCheck passes, admin data opens

The same trick works at signup, because supabase.auth.signUp accepts any options.data the caller sends. In SQL the pattern looks like using ((auth.jwt() -> 'user_metadata' ->> 'role') = 'admin'), and then the database itself hands out the access.

Supabase user_metadata vs app_metadata

user_metadataapp_metadata
Who can change itThe user, with updateUser()Only server code with the service role
Set at signup from client inputYes (options.data)No
In the JWTYesYes
Safe for roles, plans, org idsNoYes
Use it forName, avatar, preferencesRoles, plan, tenant

How it ends up in AI-built apps

The same goes for RLS policies in general: anything they read from the token must be something the user can't edit. Supabase's own guide on user data shows a signup trigger that copies first_name and last_name from raw_user_meta_data into a profiles table. That's fine for names. AI tools extend the same trigger with role or plan, or read the role from user.user_metadata because it's already in the session object and needs no extra query. Team apps do the same with org_id or tenant_id, so changing one value shows you another company's data.

What we found in public repos

We searched public GitHub code for roles and org ids read from user_metadata and ran our check on 8 files. It flagged all 8. Seven were real or likely holes: an admin check on user_metadata.role in an audit API route, RLS policies that took both the admin flag and the org_id from user metadata, and an invite flow where the invited user's role came from a field they could set themselves before accepting. One we couldn't judge from the code. That's a small sample, picked because it matched the pattern.

How to store Supabase roles safely

  1. Move roles to app_metadata

    Only the service role can write it, so set it from server code, for example supabase.auth.admin.updateUserById(id, { app_metadata: { role: "admin" } }). Set paid plans only from a verified payment webhook.

  2. Read it from app_metadata in SQL

    Use auth.jwt() -> 'app_metadata' ->> 'role'. For more than a couple of roles, use a roles table with RLS that stops users from changing their own row, and a custom access token hook (Supabase has a guide for role-based access).

  3. Fix signup triggers

    Never copy role, plan or admin flags from raw_user_meta_data. Insert a fixed default such as 'user'.

  4. Keep user_metadata for display

    Name, avatar and preferences are fine there.

BEFOREPolicy trusts user_metadata
create policy "admins read audit"
on public.audit_log for select
to authenticated
using ((auth.jwt() -> 'user_metadata'
        ->> 'role') = 'admin');
AFTERPolicy reads app_metadata
create policy "admins read audit"
on public.audit_log for select
to authenticated
using ((auth.jwt() -> 'app_metadata'
        ->> 'role') = 'admin');

Fix prompt you can paste

FIX PROMPT
Find every place this repo reads role, is_admin, plan, tier, permissions,
org_id or tenant_id from Supabase user_metadata or raw_user_meta_data:
RLS policies, SQL functions, signup triggers, API routes, middleware and
client code. Move those fields to app_metadata (set only from server code
with the service role) or to a roles table whose RLS stops users from
updating their own role. Change signup triggers to insert a fixed default
role. Keep user_metadata for display fields only. Show me each change and
write a test where a normal user calls updateUser({ data: { role: "admin" } })
and still can't reach the admin route.

How Leakcade checks this

Our user_metadata check reads your repo as text. A role, admin flag, plan or org id taken from user_metadata in SQL (policies, functions, signup triggers) is critical. The same read in server code is high, and in client code only it's medium, because it may just change what the UI shows. Display fields and app_metadata are left alone.

A badge that shows "Pro" from user_metadata is harmless. It becomes a hole when something gets unlocked because of it.

Further reading

  1. Supabase docs: Row Level Security (auth.jwt and metadata)
  2. Supabase Security Advisor: RLS references user_metadata
  3. Supabase docs: Custom claims and role-based access control
  4. Supabase docs: User management and user data
  5. Supabase JS reference: updateUser

FAQ

What's the difference between user_metadata and app_metadata in Supabase?

Users can edit user_metadata themselves with updateUser(). Only server code using the service role can change app_metadata. Both end up in the JWT, so check which one a policy reads.

Can a user change their own app_metadata?

No. It can only be set through the Admin API with the service role key, from server code you control.

Does Supabase warn about user_metadata in policies?

Yes. The Security Advisor (the splinter linter) has a check that flags RLS policies referencing user_metadata.

Why doesn't a role change show up right away?

The role is read from the JWT, which only updates when the token refreshes. Refresh the session or check a roles table for anything that must take effect immediately.

Want a second pair of eyes?

We review your whole repo and write a fix prompt for every finding. $19, report within an hour.

Check my repo

Leakcade is an automated check. It can miss vulnerabilities and is not a substitute for a professional security audit or pentest. Provided as is. You stay responsible for your code. Where the law allows, our liability is limited to what you paid ($19). Terms

Patch Notes by Leakcade

A short weekly email: what changed in AI coding tools, one real security mistake, one prompt you can reuse.