A user can change their role to admin: how to fix it
A user can make themselves admin when the app lets the user write a role, an admin flag, or a plan field, so remove that write permission and read the role from a source that only the server can change. The check is simple. Sign in as a normal user and try to set your own role to admin. If the request works, any user can do it.
This guide shows how the flaw works, how to test it, and how to fix it in a Supabase app. It also shows how to find out if someone already used it.
What does “user can change their role to admin” mean?
It means that the app trusts a value that the user controls. The value says who is an admin. The user changes the value, and the app gives admin rights.
This flaw has the name privilege escalation. A normal user gets the rights of a higher role. The attacker does not need to guess a password or break a token. The app gives the rights away.
Three patterns cause most cases in AI-generated apps:
| Pattern | How the user gets admin |
|---|---|
A role column on a table with an update policy for the own row |
The user sends an update that sets role to admin |
The role in user_metadata |
The user calls updateUser and sets the role in the metadata |
| An API route that copies the whole request body into the database | The user adds "role": "admin" to a normal request |
All three patterns have the same root cause. The app checks who the user is, but it does not check which fields the user can write.
A coding tool often creates the first pattern. You ask for a profiles table and an admin page. The tool adds a role column. It then adds a policy so that users can edit their own profile. The policy is correct for the name and the avatar. The policy is not correct for the role, because the policy covers the whole row.
How can I confirm that a user can make themselves admin?
Run the test with a normal account, not with your admin account. Use a test user that you create for this purpose.
- Sign in to your live app as the normal user.
- Open DevTools and select the Network tab.
- Edit the profile in the normal way, for example change the display name.
- Find the request that saves the change. It is a
PATCHto a/rest/v1/profilesURL, or aPOSTto your own API route. - Copy the request as a cURL command. Add the field
rolewith the valueadminto the body. - Send the request again from a terminal.
- Read the profile row. Check if the role changed.
For a Supabase table, the test looks like this:
curl -i -X PATCH \
"https://YOUR-PROJECT.supabase.co/rest/v1/profiles?id=eq.USER_UUID" \
-H "apikey: YOUR_ANON_KEY" \
-H "Authorization: Bearer USER_ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-H "Prefer: return=representation" \
-d '{"role":"admin"}'
Replace the values with the test user’s data. The Prefer header makes the response show the changed row. A response with status 200 and a row that shows "role":"admin" means that the flaw exists. A 403 error or an error about permissions means that the database blocks the write.
Warning: Do this test only on your own app, and only with a test account. Do not use a real customer account.
Test the other two patterns in the same way:
- Metadata test. In the browser console of your app, call
supabase.auth.updateUser({ data: { role: "admin" } }). If the call succeeds and your app reads the role from the metadata, the app is open. The Supabase RLS documentation states that theraw_user_meta_datacolumn can be updated by the authenticated user and is not a good place to store authorization data. - Signup test. Add
"role": "admin"to the signup request or to the first profile insert. Many apps accept the field.
What do I do first? Fix it now, in order of urgency
Do the steps in this order. Steps 1 to 3 stop new abuse. The other steps remove the cause.
Caution: Some of the steps below stop users from writing to the profiles table. Test your normal profile save on a staging copy before you run the SQL on production.
- Find the admins. Run a query that lists every user with admin rights. Compare the list with the people that you know. Remove any admin that you did not approve.
- Remove the write permission on the role column. Run the SQL in the next step. It stops the update from the browser at the database level.
- Check for other writable fields. Look at the columns
is_admin,plan,credits,verified,owner_id, andtenant_id. A user must not write any of them. - Move the role to a safe place. Use
app_metadataor a separate table that only the server can write. - Change the policies and the code that read the role. Read the role from the safe place.
- Sign out affected sessions. Old tokens keep old claims until they expire.
- Test again as the normal user. Repeat the tests from the confirm section.
Step 1: List all admins
Run this query in the SQL editor. Change the table and column names to match your schema:
select id, email, role, created_at
from public.profiles
where role = 'admin';
If you store the role in the metadata, use this query:
select id, email, raw_app_meta_data, raw_user_meta_data
from auth.users
where raw_app_meta_data ->> 'role' = 'admin'
or raw_user_meta_data ->> 'role' = 'admin';
Set the role back to the normal value for each user that you do not know.
Step 2: Block the write with a column grant
A Supabase table gives the authenticated role the update permission on all columns by default. The Supabase column-level security guide shows how to remove it. Revoke the table-level permission first. Then grant the update on the safe columns only:
revoke update on table public.profiles from authenticated;
grant update (display_name, avatar_url, bio)
on table public.profiles to authenticated;
Replace the column list with the columns that users can edit. After this change, an update that touches role fails with a permission error.
Note that the guide gives these limits. A restricted role cannot use the * wildcard on the table, so queries must name the columns. A select * or an update that includes a blocked column fails. The guide also says that this method is not the first choice for most projects. Use it for a small set of sensitive columns.
Step 3: Add a WITH CHECK clause to the update policy
A column grant is one layer. Add a second layer in the policy. The PostgreSQL documentation explains that the WITH CHECK expression defines which modified rows can be stored. It also states that when only a USING clause exists, the database uses it for both cases.
A policy with only USING (id = auth.uid()) checks the owner of the row. It does not check the new values. Add a rule that the role stays the same:
drop policy if exists "users update own profile" on public.profiles;
create policy "users update own profile" on public.profiles
for update to authenticated
using (id = (select auth.uid()))
with check (
id = (select auth.uid())
and role = (select p.role from public.profiles p where p.id = (select auth.uid()))
);
The second line of the check compares the new role with the stored role. The comparison fails if the user changes the role. Be careful with this policy on the table that it reads. If you see the error infinite recursion detected in policy, use a column grant instead of the subquery. The column grant in step 2 is simpler and has no recursion risk.
Step 4: Store the role where the user cannot write
Use one of these two places:
| Place | Who can write | Best for |
|---|---|---|
app_metadata (raw_app_meta_data) |
Only the server, with the service_role key or the Admin API |
A small number of roles in the token |
A user_roles table with no write policy for users |
Only the server and the SQL editor | Many roles, or roles per team |
The Supabase documentation says that raw_app_meta_data cannot be updated by the user. It is the correct place for authorization data.
To set the role from your server, use the Admin API. Run this code only on the server, and never in the browser:
const { error } = await supabaseAdmin.auth.admin.updateUserById(userId, {
app_metadata: { role: "admin" },
});
The supabaseAdmin client uses the service_role key. If this key is in your frontend code, read the service_role key guide first, because that is a larger leak.
A role in a separate table is also an option. Create the table and enable Row Level Security, but add no insert, update, or delete policy for the authenticated role. Then users can read their own role and cannot write it:
create table public.user_roles (
user_id uuid primary key references auth.users (id) on delete cascade,
role text not null default 'user'
);
alter table public.user_roles enable row level security;
create policy "read own role" on public.user_roles
for select to authenticated
using (user_id = (select auth.uid()));
For the full pattern, see the Supabase RLS guide.
Step 5: Update the code that reads the role
Search your code and your policies for every place that reads a role. Search for user_metadata, role, is_admin, and isAdmin. Change each place to read the safe source.
A policy that reads the role from the token must use app_metadata:
create policy "admins read all orders" on public.orders
for select to authenticated
using ((select auth.jwt() -> 'app_metadata' ->> 'role') = 'admin');
Do not write auth.jwt() -> 'user_metadata' in a policy. The user controls that claim.
Check the role on the server for every admin action. The check in the browser only changes the page. The server or the database must stop the request.
Step 6: Sign out the affected sessions
A token holds the claims from the time that it was created. If a user got a forged admin role before your fix, the old token can keep the claim until it expires. Remove the role in the data, and then end the user’s sessions. In the Supabase dashboard, open Authentication, select the user, and remove the sessions or delete the user. For many users, ask the people to sign in again.
How do I check the damage after a role change attack?
Look for admin actions that a normal user made. Use the logs that you have.
- Read the user list. Compare the admin list with your team. Look at the creation date of each admin account. An admin that was created at an odd time needs a review.
- Read the Auth logs. In the Supabase dashboard, open Logs and select the Auth logs. Look for
updateUsercalls from accounts that are not admins. - Read the API logs. Open the API Edge logs. Look for
PATCHrequests to the profiles table that include the role field. - Check the tables that admins can change. Look at orders, prices, plans, and user data. Compare the recent rows with your own records.
- Check the changes that the admin role allows. If admins can delete users or change billing, check those records first.
If your plan does not keep logs for the period of the flaw, you cannot prove that nobody used it. In that case, treat all admin data as seen.
Note: The data that was visible to admins can include personal data. If you do not know who looked at it, ask a lawyer about your duty to report a breach.
How do I stop this from happening again?
Use these controls. Each one covers a different failure.
- Write only the allowed fields. In API routes, pick the fields from the request body one by one. Do not pass the whole body to the database. This stops the mass assignment flaw. See mass assignment in AI-generated CRUD.
- Use column grants for sensitive columns. Allow updates only on the columns that users must edit.
- Never store authorization data in
user_metadata. Useapp_metadataor a table. - Check the role on the server. The browser can hide a button. Only the server can refuse a request.
- Review each policy that has
for updateorfor all. Ask this question: which columns can the user change with this policy? - Log role changes. Add a trigger that writes each role change to an audit table. Then you can see the change at once.
- Test with a normal account after each release. Add the cURL test from this guide to your release checklist.
- Give AI coding tools a rule. Tell the tool in the project rules to never let users write role, plan, or owner fields.
A short trigger for the audit log:
create table public.role_audit (
id bigint generated always as identity primary key,
user_id uuid,
old_role text,
new_role text,
changed_by uuid default auth.uid(),
changed_at timestamptz not null default now()
);
create function public.log_role_change() returns trigger
language plpgsql security definer set search_path = '' as $$
begin
if new.role is distinct from old.role then
insert into public.role_audit (user_id, old_role, new_role)
values (new.id, old.role, new.role);
end if;
return new;
end;
$$;
create trigger profiles_role_audit after update on public.profiles
for each row execute function public.log_role_change();
For other patterns of role and ownership checks, read authorization patterns. For more test cases, read privilege escalation in SaaS apps.
When should I get help?
Get help in these cases:
- The admin list has people that you do not know, and you cannot find out how they got the role.
- The logs for the period of the flaw are not available.
- Admins can see or export personal data, payment data, or health data.
- You find a leaked
service_rolekey in the same app. This is a larger problem, because that key ignores all policies.
After the fix, repeat the test as a normal user. Send the role update again. A correct app returns a permission error, and the role stays user.
Common questions
Is a role stored in user_metadata safe?
Does a hidden admin button protect my app?
Is an RLS policy with only USING enough for the profiles table?
Do I need to sign out users after I change a role?
Can an AI coding tool create this problem?
Test your role checks
A VibeEval scan signs in as a normal user and tests if the app accepts a write to a role or admin field on the profile or on a signup request.
14-day free trial · No credit card · Cancel anytime