Example finding How it works Coverage PENTEST METHODOLOGY DOCS PRICING FAQ MCP CONTACT LOG IN SIGN UP →

A user can change their role to admin: how to fix it

FIX / ROLE ESCALATION / 9 MIN READ

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.

  1. Sign in to your live app as the normal user.
  2. Open DevTools and select the Network tab.
  3. Edit the profile in the normal way, for example change the display name.
  4. Find the request that saves the change. It is a PATCH to a /rest/v1/profiles URL, or a POST to your own API route.
  5. Copy the request as a cURL command. Add the field role with the value admin to the body.
  6. Send the request again from a terminal.
  7. 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 the raw_user_meta_data column 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.

  1. 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.
  2. 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.
  3. Check for other writable fields. Look at the columns is_admin, plan, credits, verified, owner_id, and tenant_id. A user must not write any of them.
  4. Move the role to a safe place. Use app_metadata or a separate table that only the server can write.
  5. Change the policies and the code that read the role. Read the role from the safe place.
  6. Sign out affected sessions. Old tokens keep old claims until they expire.
  7. 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.

  1. 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.
  2. Read the Auth logs. In the Supabase dashboard, open Logs and select the Auth logs. Look for updateUser calls from accounts that are not admins.
  3. Read the API logs. Open the API Edge logs. Look for PATCH requests to the profiles table that include the role field.
  4. Check the tables that admins can change. Look at orders, prices, plans, and user data. Compare the recent rows with your own records.
  5. 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.

  1. 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.
  2. Use column grants for sensitive columns. Allow updates only on the columns that users must edit.
  3. Never store authorization data in user_metadata. Use app_metadata or a table.
  4. Check the role on the server. The browser can hide a button. Only the server can refuse a request.
  5. Review each policy that has for update or for all. Ask this question: which columns can the user change with this policy?
  6. Log role changes. Add a trigger that writes each role change to an audit table. Then you can see the change at once.
  7. Test with a normal account after each release. Add the cURL test from this guide to your release checklist.
  8. 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_role key 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?
No. The signed-in user can edit user_metadata with the client library. Store roles in app_metadata or in a table that the user cannot write.
Does a hidden admin button protect my app?
No. A hidden button only changes the page. The attacker sends the request directly to your API or database. The server must check the role.
Is an RLS policy with only USING enough for the profiles table?
No, not for a role column. A policy that lets a user update the own row also lets the user update every column in that row. Add a column grant or a trigger.
Do I need to sign out users after I change a role?
Yes, if the role is in the token. The old token keeps the old claims until it expires. Shorten the token lifetime or sign the user out.
Can an AI coding tool create this problem?
Yes. Tools often add a role column to the profiles table and a policy that lets users update the own row. Review each policy that touches a role.

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

SCAN YOUR APP →