Skip to content
VibeSafeDaily

Auth

Any user can make themselves admin through user_metadata

Cataloged October 19, 2026 · Lovable · Bolt · Cursor · Replit · v0 · Supabase

Symptom

The admin page and the admin-only rows check a role that looks like the app set it. Any signed-in user can change that role on their own account and pass the check.

Root cause

Supabase Auth keeps two metadata objects on each user, and both appear in the access token that auth.jwt() reads. The Users guide says user_metadata is editable by the user without any checks, and says not to use it in RLS policies or authorization logic. The row level security guide says raw_user_meta_data can be updated by the authenticated user, so it is not a good place to store authorization data, while raw_app_meta_data cannot be updated by the user. In supabase-js, updateUser({ data }) writes user_metadata from the browser. Signup takes the same path: options.data on signUp is stored as user_metadata, so a signup trigger that copies a role from new.raw_user_meta_data into a profiles table lets the person signing up choose their own role. Security Advisor lint 0015, level ERROR, flags a policy that references user_metadata. Its example policy reads auth.jwt() -> 'user_metadata' ->> 'is_admin', and the page says updateUser({ data: { is_admin: true } }) bypasses it. The lint looks at policies, so a role check in app code, middleware, or a signup trigger can still pass the advisor. Supabase issue 10058, opened November 3, 2022, reported that the updateUserById reference implied only the service role could change user_metadata, when updateUser lets the user change it too. Issue 21391, opened February 20, 2024, reported that the RLS docs then suggested an SSO policy that relied on user_metadata. The reporter showed a signed-in user could overwrite the iss value from JavaScript, and a docs warning was merged before the issue closed on February 11, 2025. vibe-code-tours/team-10-app issue 26, opened July 16, 2026, reports the same failure in an app's admin portal: a customer who changed user_metadata.role from customer to admin could open /admin, change products and categories, and view orders. The maintainer closed it on July 18, 2026 after moving the check to a role column in the database and hardening RLS with an is_admin() function. The Column Level Security guide recommends RLS policies with a dedicated table for user roles, so a role column that users can update on their own profile row is the same hole.

Before and after

Lint 0015's example reads a flag from user_metadata. The fix prompt keeps the policy and replaces that check with private.is_admin(), which reads a role table the signed-in user cannot write.

Before

auth.jwt() -> 'user_metadata' ->> 'is_admin'

After

(select private.is_admin())

Fix prompt

In this project and its own Supabase project only. Do not test against any other app or project.

1. Find every access decision that reads user_metadata.
   In the SQL editor, list policies that mention it:
   select schemaname, tablename, policyname, cmd, qual, with_check
   from pg_policies
   where (coalesce(qual, '') || ' ' || coalesce(with_check, '')) ~* 'user_meta';
   List functions that read it, such as a handle_new_user signup trigger:
   select n.nspname, p.proname
   from pg_proc p join pg_namespace n on n.oid = p.pronamespace
   where p.prosrc ~* 'user_meta'
     and n.nspname not in ('auth', 'pg_catalog', 'information_schema', 'extensions');
   In the app code, search for user_metadata, userMetadata, and updateUser({ data in route guards, middleware, API routes, server actions, and Edge Functions.

2. Keep roles where the user cannot write them. Supabase's Column Level Security guide recommends a dedicated table for user roles:
   create table if not exists public.user_roles (
     user_id uuid primary key references auth.users on delete cascade,
     role text not null default 'user'
   );
   alter table public.user_roles enable row level security;
   revoke all on table public.user_roles from anon, authenticated;
   Add no insert, update, or delete policy for authenticated. Assign roles from the SQL editor or from server code that uses the secret key.

3. Read the role through a security definer function in a schema that is not exposed, the same shape as the private schema examples in Supabase's row level security guide:
   create schema if not exists private;
   create or replace function private.is_admin()
   returns boolean
   language sql
   security definer
   set search_path = ''
   stable
   as $$
     select exists (
       select 1 from public.user_roles
       where user_id = (select auth.uid()) and role = 'admin'
     );
   $$;
   revoke execute on function private.is_admin() from public;
   grant usage on schema private to authenticated;
   grant execute on function private.is_admin() to authenticated;
   Do not add private to Exposed schemas in API settings.

4. Rewrite each policy from step 1 with alter policy. Keep its table, command, and roles. Replace the user_metadata check with (select private.is_admin()). Select and delete policies take using. Insert policies take with check. Update policies take both. Do not drop the policy and recreate it from an example.
   If the app already sets the role in app_metadata only from server code, with supabase.auth.admin.updateUserById(userId, { app_metadata: { role: 'admin' } }), a policy may read ((select auth.jwt()) -> 'app_metadata' ->> 'role') instead. Never put the secret or service_role key in the browser.

5. In each signup trigger from step 1, stop reading role, org, team, tenant, or plan from new.raw_user_meta_data. Insert the default role instead. Display fields such as a name may still come from user metadata.

6. If a profiles or users table has a role column, make sure the user cannot update it on their own row. Move the role into public.user_roles, or revoke update on that table from authenticated and grant update back only on the columns users may edit, for example:
   revoke update on table public.profiles from authenticated;
   grant update (display_name, avatar_url) on table public.profiles to authenticated;
   Use this table's real editable columns.

7. In app and server code, replace user_metadata role checks. Server code verifies the user with supabase.auth.getUser(), then reads that user's row from public.user_roles with a server-only client that uses the secret key, filtered by the verified user id, or reads app_metadata. Hiding a link or a route in the UI is not a check.

8. Do not copy existing user_metadata roles into user_roles. Grant admin only to the user ids you know are admins. Re-run Security Advisor and confirm lint 0015 no longer appears.

Do not print tokens, keys, or user rows. Do not edit unrelated files.

Paste this into the coding tool that has your own project open.

2-minute self-check

These steps are for an app you own. Do not run them against someone else's project.

  1. In your own SQL editor, run select tablename, policyname, cmd from pg_policies where (coalesce(qual, '') || ' ' || coalesce(with_check, '')) ~* 'user_meta'. A row that grants access from a role or flag is the failure. Security Advisor lint 0015 flags the same kind of policy.
  2. Open your own signup trigger, often handle_new_user, and search it for raw_user_meta_data. If it copies a role, org, or plan from there, the person signing up picks their own.
  3. Signed in to your own app as a fresh test account, call updateUser({ data: { role: 'admin' } }) on your app's Supabase client, using the key your app checks, then call refreshSession(). The admin page and one admin-only table should still be denied. Do not run this against an app you do not own.

Common questions

The admin page checks a role that the app set on the user. Why can any signed-in user pass it?

Supabase Auth keeps user_metadata on the user, and that object is in the access token that auth.jwt() reads. The Users guide says user_metadata is editable by the user without any checks, and says not to use it in RLS policies or authorization logic. In supabase-js, updateUser({ data }) writes user_metadata from the browser. Security Advisor lint 0015 flags a policy that reads auth.jwt() -> 'user_metadata' ->> 'is_admin'. The page says updateUser({ data: { is_admin: true } }) bypasses it. The lint looks at policies, so the same check in app code, middleware, or a signup trigger can still pass the advisor.

The role is copied into a profiles table at signup, or stored on app_metadata. Which of those can the user still set?

options.data on signUp is stored as user_metadata, so a signup trigger that copies a role from new.raw_user_meta_data lets the person signing up choose their own role. The row level security guide says raw_user_meta_data can be updated by the authenticated user, while raw_app_meta_data cannot. A role in app_metadata is safe only when server code sets it with the secret key, for example supabase.auth.admin.updateUserById(userId, { app_metadata: { role: 'admin' } }). A role column the user can update on their own profile row is the same hole. The Column Level Security guide recommends a dedicated table for user roles, with no insert, update, or delete policy for authenticated.

Sources

Same symptom

Same platform