All posts
VibeZero Team7 min read

Privilege escalation, explained

The user_metadata field every generated Supabase app trusts is writable by the user. Why role checks belong in a table, and the policy pattern that holds.

  • security
  • vibe coding
  • engineering

Privilege escalation is the step where an attacker stops being one of your users and starts being one of you. It is not a single bug, it is an outcome, and almost every route to it comes down to one question: where does your application learn what a caller is allowed to do, and can the caller influence that source?

The identifier is CWE-269, improper privilege management. It sits under broken access control, which OWASP ranks first, and it is the vertical sibling of IDOR: IDOR gets you another user’s data, escalation gets you every user’s data plus the buttons that change it.

The two shapes

Vertical escalation crosses a role boundary. A regular user becomes an admin, a viewer becomes an owner, a free account gains a paid capability. The attacker gains permissions that exist in the system but were not theirs.

Horizontal escalation crosses a tenant or ownership boundary at the same permission level. Reading another organization’s records as an ordinary member of your own is horizontal, and in a multi-tenant product it is usually the more damaging of the two, because it scales to every customer at once.

Both are worth naming separately because they are found differently. Vertical escalation is usually a bug in one check. Horizontal escalation is usually a missing predicate repeated in fifty queries.

The one that ships constantly: user_metadata

If you build on Supabase, this is the escalation you most likely have right now, and it is worth walking through in full because nothing about it looks wrong.

Supabase gives every auth user two metadata objects, and the difference between them is the entire security boundary:

  • user_metadata (raw_user_meta_data in the database) is intended for user-editable profile data such as a display name or an avatar. A signed-in user can write it themselves with supabase.auth.updateUser({ data: {...} }).
  • app_metadata is writable only with the service role key, never by the user.

The Supabase documentation on managing user data states this plainly and warns against using user_metadata for authorization. The warning exists because the mistake is so easy to make: both objects are present in the JWT, both look official, and the one with the friendlier name is the wrong one.

So this policy, which appears in a very large number of generated projects, grants admin rights to anybody who asks for them:

-- Vulnerable. user_metadata is writable by the user it describes.
create policy "admins read all orders" on public.orders
  for select to authenticated
  using ((auth.jwt() -> 'user_metadata' ->> 'role') = 'admin');

The attack is one line in the browser console of your own app, from an ordinary signed-in account:

await supabase.auth.updateUser({ data: { role: "admin" } });
// Refresh the session so the new claim is in the JWT, then read everything.

No exploit, no tooling. The user updated their own profile, which is what user_metadata is for, and the policy read the profile as an authorization decision.

The fix: roles live in a table

app_metadata is safe from the user, and it is still not where a role belongs long term, because changing it requires the service role key and it is awkward to audit. The durable pattern is a table the user cannot write, read through a function that runs with the definer’s rights.

-- 1. Roles in their own table. No policy grants insert or update to `authenticated`,
--    so the only way in is the service role or a migration.
create table public.user_roles (
  user_id uuid primary key references auth.users on delete cascade,
  role text not null check (role in ('member', 'admin')),
  granted_at timestamptz not null default now(),
  granted_by uuid references auth.users
);
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 = auth.uid());

-- 2. A definer function so policies can read the table without granting access to it.
create or replace function public.is_admin()
returns boolean
language sql
stable
security definer
set search_path = public, pg_temp   -- required: prevents search_path hijacking
as $$
  select exists (
    select 1 from public.user_roles
    where user_id = auth.uid() and role = 'admin'
  );
$$;

revoke execute on function public.is_admin() from public;
grant execute on function public.is_admin() to authenticated;

-- 3. Policies ask the function, never the token.
create policy "admins read all orders" on public.orders
  for select to authenticated
  using (public.is_admin());

Three details are load-bearing. security definer lets the function read user_roles even though the caller’s policies would not; without it the check returns false for everyone. set search_path is mandatory on any definer function, because a caller who can create objects in a schema earlier on the path could otherwise shadow the table the function reads, which is its own escalation. And stable lets Postgres call it once per query rather than once per row, which is the difference between this pattern being usable and being a performance problem on a large table.

If you need the role inside the JWT for the frontend to render the right nav, use a custom access token hook, which lets Supabase copy the role from your table into the token at issue time. The token then carries a claim the user cannot set. Treat the frontend’s use of it as cosmetic regardless: a hidden button is not a permission.

The other routes in

The Supabase case is the most common one right now, but every one of these is the same category error, and each is worth a specific check.

A writable role column. If profiles has a role and your update policy authorizes the row without constraining the columns, the client writes it directly. That is mass assignment, and the fix is column-level grants: revoke update on public.profiles from authenticated; followed by grant update (display_name, avatar_url) on public.profiles to authenticated;

Trusting a client-supplied field. role in the signup body, plan in the checkout payload, tenant_id in a request header, isAdmin in localStorage. Any of these read on the server is escalation with no attacker skill involved.

Client-side guards only. A React route that renders <AdminPanel/> when user.role === "admin" is a UI affordance. The admin API is still reachable with curl. If your admin endpoints do not each perform their own check, the guard is decoration.

An unauthenticated internal endpoint. The /api/admin/* route protected by “nobody knows the URL,” the debug endpoint left in, the seeding route that grants a role. These are found by anyone who reads your JavaScript bundle.

Verification and plan flags. Not everything valuable is called admin. Writing email_verified, plan, credits, or subscription_status skips a process that exists for a reason. We enforce these server-side for exactly that reason: a plan capability check that lives only in the SPA is a suggestion, so the control plane refuses the gated action itself and answers with the workspace’s real entitlements.

The service role key in the browser. The final boss, and it is not really escalation so much as surrender: that key bypasses row level security entirely. If it appears in any client bundle or NEXT_PUBLIC_ variable, every other control in this post is irrelevant. See API key exposure for what to do about a key that has already shipped, and note that rotating it is only half the job.

Why this class survives review

Read the vulnerable policy at the top again. It has row level security enabled, it names the authenticated role, it checks a claim from a signed JWT, and the signature on that JWT is genuinely valid. Every individual property a reviewer looks for is present. The defect is one level up: the claim is authentic and its content was chosen by the subject.

That is the same shape as the Moltbook incident and the same shape as RLS misconfiguration generally. The question to train yourself to ask is not “is this checked” but “who last wrote the value being checked.”

What a scanner can see

Partially, and the split matters.

OpenGrep (app/engines/opengrep.py) matches the code-side shapes: a role read from a request body, an admin comparison against a client-supplied value, a NEXT_PUBLIC_ variable holding something that looks like a service key. Gitleaks and TruffleHog (gitleaks.py, trufflehog.py) catch the committed service role key, and TruffleHog verifies it against the provider so you know whether it is still live.

The user_metadata policy is harder, because the finding is in a SQL file describing a data model, and judging it requires knowing which fields of a specific auth provider are user-writable. That is a config-layer and database-layer question rather than a code-layer one, which is why our release decision scans those as separate layers and treats a clean code scan as evidence about code only.

The rule

Authorization data must come from somewhere the subject cannot write. A signed token proves who wrote the claim, never who chose its value. Put roles in a table, read them through a function that runs with rights the caller does not have, and check them on the server on every request, including the ones your interface never offers.

ShareXLinkedIn