PL-020highCode

Admin check only in the browser

The admin page is hidden in the app, but nothing stops a normal user asking the database for the same data.

Severity
High
Who can exploit it
Anyone who signs up, sometimes anyone
Found by
PatchLabs Code: your frontend code
Lab proof
Not lab-proven: file and line in the report

What it is

A route guard such as if (!isAdmin) return <Navigate to="/" /> decides what the app shows. It does not decide what the database returns. Everything in the browser, including the admin check and the Supabase client, is under the user's control: they can run the same queries from the console or with curl, using the public key from your bundle and their own session.

PL-020 means admin access is decided in frontend code: comparing the signed-in email with a hard-coded address, reading user_metadata.role (PL-019), or checking a password stored in a VITE_ variable. Whether that is a hole depends on the database. If the tables behind the admin page have their own admin-only rules, the guard is just good manners. If they don't, every user can read and change what the admin page manages.

Why AI builders produce it

Ask for an admin dashboard and you get a protected route and a page, which looks finished: a normal user who opens the link is sent away. Enforcing it in the database needs a roles model and policies, which the request never mentioned.

Example of vulnerable code

Simplified from the patterns we see in AI-built apps. Synthetic: not taken from any real app.

src/pages/Admin.tsx (vulnerable)
// src/pages/Admin.tsx
const ADMIN_EMAIL = 'founder@example.com';

export default function Admin() {
  const { user } = useAuth();
  const [orders, setOrders] = useState<Order[]>([]);

  useEffect(() => {
    supabase.from('orders').select('*').then(({ data }) => setOrders(data ?? []));
  }, []);

  if (user?.email !== ADMIN_EMAIL) return <Navigate to="/" />;
  return <OrdersTable orders={orders} />;
}

-- Meanwhile, in the database, any signed-in user can read every order:
-- create policy "Signed-in users read orders" on public.orders
--   for select to authenticated using (true);

Check it yourself

Sign in to your app as a normal test user, copy that user's access token (in the browser's local storage, under the key starting sb- and ending -auth-token, field access_token), and ask for the admin data directly:

check-pl-020.sh
curl "https://<project>.supabase.co/rest/v1/orders?select=*&limit=5" \
  -H "apikey: <your public anon key>" \
  -H "Authorization: Bearer <the normal user's access token>"
# Rows that aren't this user's mean the admin page's data is open to everyone

# Where the app decides "admin" in the browser
grep -rnE "isAdmin|role ?=== ?['\"]admin|ADMIN_EMAIL|user_metadata" src

The fix

Put the rule in the database, where the user can't edit it, then keep the route guard for a tidy experience.

supabase/migrations/…_admin_rules.sql
-- A helper policies can call; it answers only about the caller
create or replace function public.has_role(_role text)
returns boolean
language sql
stable
security definer
set search_path = ''
as $$
  select exists (
    select 1 from public.user_roles
    where user_id = (select auth.uid()) and role::text = _role
  );
$$;
revoke execute on function public.has_role(text) from public, anon;
grant execute on function public.has_role(text) to authenticated;

drop policy "Signed-in users read orders" on public.orders;
create policy "Customers read own orders" on public.orders
  for select to authenticated using ((select auth.uid()) = user_id);
create policy "Admins read all orders" on public.orders
  for select to authenticated using ((select public.has_role('admin')));

In the app, decide whether to show the page from the same source: const { data: isAdmin } = await supabase.rpc('has_role', { _role: 'admin' }). The roles table itself must not be writable by users (PL-013).

Code findings come with the file, the line and a one-line fix in the report, for your engineer to make. PatchLabs never pushes to your repo.

How PatchLabs finds it

code scan · report excerpt
[SCAN]    src/pages/Admin.tsx:11
          admin decided by email      LEAK
          compare: tables the page reads
[LAB]     orders → stranger reads    
          see PL-002 in the same report
synthetic exampleReport excerpt · read from the repo, not lab-proven

PatchLabs Code flags admin gates decided in the browser, with the file and line. Whether the data behind the page is open is a database question, and the Lab answers it for reads: if a signed-in stranger can read those tables, the same report shows PL-002 with the lab proof.

When the pattern is intended

Hiding admin screens from non-admins is good practice; it is only a problem when it is the only control. If every table the page touches already has an admin-only rule in the database, the guard is just presentation and there is nothing to fix.

Why it’s rated high

High: what sits behind an admin page is usually everyone's data. Not critical by default, because the database rules may already be right, and code alone can't tell.

Guides

Free check

Does your app have PL-020?

Paste your public repo. PatchLabs Code reads it for this and every other code finding, and the Lab rebuilds your database to test who can read what.

Free for your first appReads your public repo onlyOr run the free static RLS check

Last reviewed 10 Nov 2026. Finding IDs are stable: link to them freely. Spotted a mistake? Tell us.