Access decided by user_metadata, which users can edit
Roles stored where users can rewrite them. One call from the browser and they're an admin.
- Severity
- Critical (high for plans and credits)
- Who can exploit it
- Anyone who signs up
- Found by
- PatchLabs Code: SQL policies, triggers and app code
- Lab proof
- Not lab-proven: file and line in the report
What it is
Every Supabase user has two metadata objects. user_metadata (stored as raw_user_meta_data) is for profile details, and the user can set it: at sign-up through options.data, and at any time with supabase.auth.updateUser({ data: … }). app_metadata (raw_app_meta_data) can only be changed with the service role key, from a server.
Both appear in the user's access token, so a policy can read either through auth.jwt(). PL-019 means something decides access from user_metadata: a policy such as (auth.jwt() -> 'user_metadata' ->> 'role') = 'admin', a sign-up trigger that copies raw_user_meta_data ->> 'role' into a role column, or frontend code that shows admin screens when user.user_metadata.role === 'admin'. Any user can give themselves that value.
Why AI builders produce it
Sign-up forms collect a name and sometimes an account type, and the natural place for extra sign-up fields is options.data, which lands in user_metadata. When roles come up later, a role is already sitting there, readable from both the token and the frontend, so it gets used.
Example of vulnerable code
Simplified from the patterns we see in AI-built apps. Synthetic: not taken from any real app.
-- A policy that trusts a value the user controls
create policy "Admins read all profiles"
on public.profiles for select to authenticated
using ((auth.jwt() -> 'user_metadata' ->> 'role') = 'admin');
-- A sign-up trigger that copies the user's chosen role into the database
create or replace function public.handle_new_user()
returns trigger language plpgsql security definer set search_path = ''
as $$
begin
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'));
return new;
end;
$$;
-- What any user can run in the browser:
-- await supabase.auth.updateUser({ data: { role: 'admin' } })
-- await supabase.auth.refreshSession() -- the new token says admin
Check it yourself
-- 1. Policies that read user_metadata
select schemaname, tablename, policyname, qual, with_check
from pg_policies
where coalesce(qual, '') ilike '%user_metadata%'
or coalesce(with_check, '') ilike '%user_metadata%';
-- 2. Your functions and triggers that read raw_user_meta_data
select n.nspname as schema, p.proname as function_name
from pg_proc p join pg_namespace n on n.oid = p.pronamespace
where n.nspname not in ('pg_catalog', 'information_schema', 'auth', 'storage')
and p.prosrc ilike '%raw_user_meta_data%';
A function that copies full_name or an avatar is fine; look for role, plan, credits or anything like is_admin. In the frontend: grep -rn "user_metadata" src.
The fix
Keep anything that grants access in app_metadata, which only your server can write, or in a roles table users can't write (see PL-013).
drop policy "Admins read all profiles" on public.profiles;
create policy "Admins read all profiles"
on public.profiles for select to authenticated
using ((auth.jwt() -> 'app_metadata' ->> 'role') = 'admin');
-- and in handle_new_user(): always insert role 'user', never the sign-up value
// On a server or in an Edge Function, with the service role key
await admin.auth.admin.updateUserById(userId, {
app_metadata: { role: 'admin' },
});
// The user's token picks this up on its next refresh.
Code findings come with the file, the line and a one-line fix in the report, for your engineer to make. PatchLabs never pushes to your repo.
How PatchLabs finds it
[SCAN] supabase/migrations/…_auth.sql:4 user_metadata ->> 'role' LEAK src/components/AdminRoute.tsx:9 user_metadata.role === 'admin' [FIX] app_metadata or a roles table
PatchLabs Code reads your SQL (policies, triggers and functions in your migrations) and your app code, and flags any place where a role, admin flag, plan or credit balance is read from user_metadata. Role and admin checks are rated critical; plans and credits high. The report gives the file and line. A Lab test that signs a stranger in with an edited user_metadata and re-reads your tables is coming; until then this is a code finding, not a lab proof.
When the pattern is intended
user_metadata is the right place for display details the user owns: name, avatar URL, preferred language. It is never the right place for anything that grants access, a plan or credits.
Why it’s rated critical (high for plans and credits)
Critical: a one-line privilege escalation from the browser, needing only an account.
Supabase linter
- 0015 rls_references_user_metadata
- Flags policies that read
user_metadata. It doesn't look at triggers or frontend code.
Related findings
Users can make themselves admin
A role column or roles table that users can write to. One request turns a normal account into an admin.
PL-020Admin check only in the browser
The admin page is hidden in the app, but nothing stops a normal user asking the database for the same data.
PL-018Sign-ups need no email confirmation
Anyone can create a working account with any email address, including one they don't own.
Guides
- user_metadata vs app_metadata: where to store roles in Supabase
- Pre-launch security checklist for vibe-coded apps
Free check
Does your app have PL-019?
Paste your public repo. PatchLabs Code reads it for this and every other code finding, and the Lab rebuilds your database to test who can read what.
Last reviewed 10 Nov 2026. Finding IDs are stable: link to them freely. Spotted a mistake? Tell us.