Skip to content

Your admin check asks the user whether they are an admin

Somewhere in your app there is a line that decides who is an administrator. In apps built with AI it is usually reading a field the user is allowed to write, which means the answer to "are you an admin" is whatever they last said it was. This takes one line in a browser console to exploit and about ten minutes to fix properly.

Loïc GuillebeauLoïc Guillebeau16 August 2026

This is the same family as the guide on tables anyone can read, one floor up. That one is about whether a wall exists. This one is about a wall that exists, has a door, and asks the person knocking to state their own clearance.

How the role gets there

You ask for an admin area. The model needs somewhere to keep the fact that somebody is an admin, and at signup there is exactly one convenient place: the metadata bag Supabase lets you attach to a new user. It works immediately, it survives a page reload, and it is visible from the client, which is where the admin menu needs it.

// at signup
await supabase.auth.signUp({
  email,
  password,
  options: { data: { role: "user" } },   // lands in user_metadata
})

// later, deciding what someone may do
const { data: { user } } = await supabase.auth.getUser()
if (user?.user_metadata?.role === "admin") {
  // show the admin area, allow the destructive action
}

Why that field is not a permission

user_metadata is designed to be written by the user it belongs to. It is where you keep a display name, a preferred language, whether they want the dark theme. Supabase documents it as unsafe for authorisation, and the reason is one function call: any signed-in user can update their own metadata, from the browser, with the anon key that is already in the page.

// pasted into the console of your own app, by anyone with an account
await supabase.auth.updateUser({ data: { role: "admin" } })
// reload

There is no exploit here, no unusual tooling, nothing that looks like an attack in a log. It is the documented way to change your own profile, used to change the one field that was never a profile field. And because the value now really is admin, everything downstream that trusts it is behaving correctly.

app_metadata is the sibling field, it looks identical in the token, and it is the opposite: it can only be written with the service_role key or the admin API, never by the user. Reading role from app_metadata instead of user_metadata is a one-word change in your code and the entire difference between a claim and an assertion.

The version that moved to a table and kept the bug

The more advanced version of this app has already moved past metadata: there is a profiles table, and it has a role column, and the checks read from there. That is the right direction, and it is often still writable by the person it describes, because the table came with the policy every profiles table comes with.

-- the policy that ships with every profiles table
create policy "users can update own profile"
  on public.profiles for update
  using ((select auth.uid()) = id);

-- which also permits, from the browser:
-- update profiles set role = 'admin' where id = auth.uid();

Row Level Security is enabled, the policy is correct as written, and the user can still promote themselves. Postgres policies grant or deny a whole row operation: unless you say otherwise, permission to update your name is permission to update every column on that row, including the one that decides what you are allowed to do.

And the version where the check lives in the component

The third shape does not involve the role being writable at all. The role is correct, the check is real, and it runs in the browser: the admin link is hidden, the button is not rendered, the page redirects non-admins away. Underneath, the endpoint that deletes a user or exports the customer list answers anyone who asks it.

A hidden button is a user interface decision. The request it would have sent can be typed by hand, and this is the same principle as the checkout that trusts an amount from the browser in the payments guide: anything the client sends is a suggestion, including the client's opinion of who it is.

Check it yourself, in two minutes

On your own app, signed in as a throwaway account that should have no privileges. The first check is the whole diagnosis: open the console on your deployed site and run the promotion, then reload.

await supabase.auth.updateUser({ data: { role: "admin" } })
// if the admin area appears after a reload, the role was never a permission

Then, in the Supabase SQL editor, look at what your update policies actually permit, and check whether a role column is sitting on a table users are allowed to write.

select tablename, policyname, cmd, qual, with_check
from pg_policies
where schemaname = 'public' and cmd in ('UPDATE', 'ALL')
order by tablename;

And last, the one that catches the hidden button: call an admin-only endpoint directly with an ordinary user's token. If it answers, the interface was the only thing standing there.

# token from the browser: JSON.parse(localStorage.getItem(
#   Object.keys(localStorage).find(k => k.endsWith('-auth-token'))
# )).access_token

curl -s -o /dev/null -w '%{http_code}\n' \
  https://your-app.com/api/admin/users \
  -H "Authorization: Bearer $TOKEN"

# 401 or 403 is the answer you want.

Put the role where the user cannot reach it

Roles belong in their own table, with no policy that lets anyone write it. A table with RLS enabled and only a select policy is closed to every client: writes fail for the user, and your server, holding the service key, is unaffected.

create table public.user_roles (
  user_id uuid primary key references auth.users on delete cascade,
  role text not null default 'member'
);
alter table public.user_roles enable row level security;

-- read your own row. there is deliberately no insert, update or delete policy
create policy "read own role" on public.user_roles
  for select using ((select auth.uid()) = user_id);

-- security definer so a policy can call it without recursing into RLS
create function public.is_admin() returns boolean
language sql security definer stable set search_path = ''
as $$
  select exists (
    select 1 from public.user_roles
    where user_id = (select auth.uid()) and role = 'admin'
  );
$$;

Then the checks that matter read that function, in the database, where they cannot be skipped by a request the frontend never planned to send.

create policy "admins read every order" on public.orders
  for select using ((select auth.uid()) = user_id or public.is_admin());
  • Migrate the existing roles before you switch the checks, and remove the old field afterwards. Two sources of truth for the same fact is how an admin loses access on a Sunday.
  • The frontend can keep hiding the admin menu. That is good interface design and it is not the check. Keep both, and be clear with yourself about which one is load-bearing.
  • If you need the role inside the JWT, put it in app_metadata or emit it with a custom access token hook. Never copy it from the client into a request body: the server derives who you are from the token, never from what the request claims.
  • Grant the service key to nothing that faces the internet without an authorisation check of its own. Moving the write behind the server only helps if the server asks who is calling.
  • Exercise the real flows afterwards, as a normal user and as an admin. A role check that is too strict fails loudly at the worst moment, usually on the account belonging to whoever signed the invoice.

Why nothing pointed at your URL will find this

From outside, this app is well behaved. Anonymous visitors get bounced from the admin route, the API returns 401 without a token, the login works. Every probe a scanner can send comes back correct, because the failure only appears after a legitimate signup, from a legitimate session, using a documented function to change a field the app happened to build its permission model on.

Finding it means reading two things together: where the role is written, and what the checks trust. That is a question about a repository, not about a URL. Grace reads it there, follows the field from the signup call to every policy and component that depends on it, and ranks what it finds by what it would cost you if someone noticed. The audit is free, read-only, and changes nothing.

Loïc Guillebeau

Loïc Guillebeau

Founder, Grace · founder of Beyond the Brackets

Seven years running an engineering agency, ten AI-built applications audited in the last three months. Grace came out of both.

More about who is behind Grace →

Want to know what else is in there?

Grace reads the repository and comes back with the architecture map, the risks ranked by severity, and a health score. It is free, it is read-only, and it changes nothing.