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.
- Severity
- Critical
- Who can exploit it
- Anyone who signs up
- Found by
- Not tested yet: coming to PatchLabs Lab
- Status
- Coming
PatchLabs does not test for this yet (coming). This page explains the problem, how to check your own project and how to fix it. The Lab’s live checks today are PL-001 to PL-008; this one is not part of any report until it ships. Shipping dates go in the changelog.
What it is
Many apps keep each user's role in the database: a role column on profiles, or a separate user_roles table read by a helper such as has_role(). Every admin policy then trusts that value. PL-013 means a normal user can write it.
Two shapes are common. In the first, users may update their own profile row and nothing limits which columns they change, so update profiles set role = 'admin' on their own row succeeds (the policy shape is PL-004). In the second, the roles table has an insert policy like with check (auth.uid() = user_id), meant as "users can create their own role row", which also lets a user insert the row (their id, 'admin').
Why AI builders produce it
AI builders are good at producing a roles table, a has_role function and admin policies, which looks like a complete design. The write side gets less attention. The sign-up flow needs to create a role row, the quickest way to allow that is to let users insert their own, and nobody constrains the value. Profile edit forms add a generic "users can update own profile" policy that covers every column, including role.
Example of vulnerable code
Simplified from the patterns we see in AI-built apps. Synthetic: not taken from any real app.
create type public.app_role as enum ('admin', 'user');
create table public.user_roles (
id bigint generated always as identity primary key,
user_id uuid not null references auth.users (id) on delete cascade,
role public.app_role not null default 'user',
unique (user_id, role)
);
alter table public.user_roles enable row level security;
create policy "Users can read own roles"
on public.user_roles for select to authenticated
using (auth.uid() = user_id);
-- Meant for sign-up. Also lets a user insert (their id, 'admin').
create policy "Users can insert own role"
on public.user_roles for insert to authenticated
with check (auth.uid() = user_id);
Check it yourself
-- 1. Write policies on tables that hold roles or privileges
select p.tablename, p.policyname, p.cmd, p.with_check
from pg_policies p
where p.schemaname = 'public'
and p.cmd in ('INSERT', 'UPDATE', 'ALL')
and exists (
select 1 from information_schema.columns c
where c.table_schema = 'public' and c.table_name = p.tablename
and c.column_name in ('role', 'roles', 'is_admin', 'plan', 'credits', 'is_verified')
);
-- 2. Can signed-in users write a role column directly?
select table_name, column_name, privilege_type
from information_schema.column_privileges
where table_schema = 'public' and grantee = 'authenticated'
and privilege_type in ('INSERT', 'UPDATE')
and column_name in ('role', 'is_admin');
Then try it. Use the id of a real, normal test user in your project (the foreign key needs one); the transaction is rolled back.
begin;
set local role authenticated;
select set_config('request.jwt.claims',
'{"sub":"<a normal test user id>","role":"authenticated"}', true);
insert into public.user_roles (user_id, role)
values ('<a normal test user id>', 'admin'); -- INSERT 0 1 means self-promotion works
rollback;
The fix
Users shouldn't write roles at all. New users get the default role from a trigger, and admins are granted from the server (with the service role key) or the SQL editor.
drop policy "Users can insert own role" on public.user_roles;
create or replace function public.handle_new_user_role()
returns trigger
language plpgsql
security definer
set search_path = ''
as $$
begin
insert into public.user_roles (user_id, role) values (new.id, 'user');
return new;
end;
$$;
create trigger on_auth_user_created_role
after insert on auth.users
for each row execute function public.handle_new_user_role();
For a role column on profiles, limit which columns users may change. The column grant is what blocks the role change:
revoke update on public.profiles from authenticated;
grant update (full_name, avatar_url) on public.profiles to authenticated;
PatchLabs doesn’t write this fix yet. Review the SQL with your engineer and add it as a new migration.
How PatchLabs will test it
When this check ships, the Lab will run it the way it runs PL-001 to PL-008 today: rebuild your database from supabase/migrations in a disposable Postgres, seed fake rows for two users, and try the exploit as an anonymous visitor, a signed-in stranger and the owner. Until then, the self-check above is the test.
When the pattern is intended
Never for roles that unlock other people's data. A user choosing a harmless account type at sign-up ("buyer" or "seller") can be fine if neither type grants access to anything extra. Constrain the values so "admin" can't sneak in: with check (auth.uid() = user_id and role in ('buyer', 'seller')).
Why it’s rated critical
Critical. An admin role usually unlocks every admin policy and every admin screen at once, and getting it needs only a free account and one request.
Related findings
UPDATE policy missing WITH CHECK
Users may edit their own row, and nothing stops them changing its role, plan or owner.
PL-019Access 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.
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.
Guides
Free check
Check what PatchLabs does test today
This check is coming. Today’s free check rebuilds your database from your migrations, tests it as a visitor, a stranger and the owner (PL-001 to PL-008), and reads your repo for keys and risky code.
Last reviewed 10 Nov 2026. Finding IDs are stable: link to them freely. Spotted a mistake? Tell us.