PL-019critical (high for plans and credits)Code

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.

supabase/migrations/…_auth.sql (vulnerable)
-- 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

check-pl-019.sql
-- 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).

supabase/migrations/…_fix_roles.sql
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
granting a role from the server
// 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

code scan · report excerpt
[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
synthetic exampleReport excerpt · read from the repo, not lab-proven

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.

Guides

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.

Free for your first appReads your public repo onlyOr run the free static RLS check

Last reviewed 10 Nov 2026. Finding IDs are stable: link to them freely. Spotted a mistake? Tell us.