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
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:
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.
-- 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
[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
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.
Related findings
Access decided by user_metadata, which users can edit
Roles stored where users can rewrite them. One call from the browser and they're an admin.
PL-002Any signed-in user can read every row
Signing up unlocks other people's rows. If sign-up is open, that means anyone.
PL-013 · comingUsers can make themselves admin
A role column or roles table that users can write to. One request turns a normal account into an admin.
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.
Last reviewed 10 Nov 2026. Finding IDs are stable: link to them freely. Spotted a mistake? Tell us.