HomeGuidesClient-side auth is not security: protect your admin page
Guide
Client-side auth is not security: protect your admin page
A JS role check, localStorage flag, or email gate only hides your admin page. Enforce roles on the server with Supabase RLS, Next.js, Firebase, or Clerk.
Independent notes from VibeLocked. Not a pentest, and not affiliated with the tools named below.
A lot of AI-built apps have an admin area that looks locked. There is a login screen, the Admin link only appears for you, and a normal user who types /admin gets bounced to the home page. Then someone opens the page source, or calls the API the page uses, and the data comes back anyway.
That is the difference between hiding something and protecting it. A check that runs in the browser decides what the screen shows. It does not decide what the server sends. OWASP's A01 Broken Access Control puts it plainly: "Access control is only effective when implemented in trusted server-side code or serverless APIs, where the attacker cannot modify the access control check or metadata." The OWASP ASVS makes it a level 1 requirement (8.3.1): enforce authorization at a trusted service layer, not with "controls that an untrusted consumer could manipulate, such as client-side JavaScript." MITRE even has a name for the bug: CWE-602, Client-Side Enforcement of Server-Side Security.
This guide shows the patterns AI tools tend to write, how each one is bypassed, and the server-side fix for Supabase, Next.js, Firebase, and Clerk. If your problem is one user reading another user's rows, start with IDOR in Next.js and Supabase instead. This page is about admin and role checks.
Checked against the OWASP, MITRE, Supabase, Next.js, Firebase, and Clerk docs and the MyBroadband reporting linked below on October 19, 2026. Product names are here to help you find your stack. VibeLocked is independent and not affiliated with any of them.
The short version
- The browser is the user's computer. Anything it runs, they can read, pause, and change. Client checks are UI, not security.
- Protect the data, not the page. The question is never "can they see
/admin?" It is "what does the API or database return to them?" - Roles come from data the user cannot edit: Supabase
app_metadataor a server-written table, Firebase custom claims, ClerkpublicMetadata. Neveruser_metadata,localStorage, a cookie you did not sign, or arolecolumn the user can update. - Check on every server entry point: RLS or Security Rules for direct database access, plus Route Handlers, Server Actions, Server Components, and Edge Functions.
- Do not send admin data before the check. A login screen that ships the data with it protects nothing.
A real example: the login screen that shipped the data
In October 2026, MyBroadband reported that an internal dashboard used by Bootlegger, the South African coffee franchise, showed a login screen but "was sending a significant amount of data down with the login page, which was visible in the source for anyone with the URL to find." MyBroadband said it verified names and contact information linked to 132 Bootlegger locations, plus some performance and financial data. The dashboard address had surfaced in photos of laptop screens that employees posted on Instagram.
In a follow-up, CEO Ricky Ruthenberg said it was a legacy version of the company's BOS operations dashboard that had not been fully decommissioned, that "certain information on the legacy platform was not protected by its login," that it held no customer data, and that the platform was taken offline on 15 October 2026. MyBroadband's first report said Ruthenberg appeared to have encouraged or directly developed the dashboard using Claude. Neither report says exactly how the code was built, so we will not guess. The lesson does not depend on it: the login form controlled what was drawn, and the data was already in the browser.
Five patterns that only protect the screen
1. A role check that hides the admin link or page
// BAD (React, Vite, or a Next.js client component): decides what to render, protects nothing
export default function AdminPage() {
const { user } = useAuth();
if (user?.role !== 'admin') return <Navigate to="/" />;
return <AdminDashboard />; // fetches /api/admin/orders or supabase.from('orders')
}
Bypass: the redirect never matters. AdminDashboard loads its data from an API route or straight from Supabase, and those endpoints are visible in the Network tab and in the JavaScript bundle. Anyone can call them without the page. If the endpoint does not check the role itself, it answers.
2. A hardcoded password in client code
// BAD: shipped to every visitor
if (input === import.meta.env.VITE_ADMIN_PASSWORD) setUnlocked(true);
Bypass: Vite inlines VITE_ variables and Next.js inlines NEXT_PUBLIC_ variables into the built JavaScript, so the password sits in a file every visitor downloads. MITRE files this under CWE-603, Use of Client-Side Authentication and CWE-798, hard-coded credentials. You also cannot rate limit a comparison that runs on the attacker's machine, while a real login can be (login brute force and credential stuffing protection). And it guards nothing anyway, because the data behind the "unlocked" screen comes from an API that never saw the password. See keep secrets out of frontend code for what else ends up in the bundle.
3. An email gate in the browser
// BAD: client-only allowlist
const isAdmin = user?.email === 'me@example.com';
Bypass: same as pattern 1. The check lives in code the visitor controls, and the queries behind it are reachable without it. An email allowlist can be fine on the server, read from a verified token, but a user id or a role is sturdier than an email string. If you do match on email, keep email confirmation on so nobody can register an address they do not own (Supabase Auth settings).
4. An isAdmin flag in localStorage, a cookie, or the URL
// BAD: the user can set all of these
localStorage.setItem('isAdmin', 'true');
const isAdmin = searchParams.get('admin') === '1';
Bypass: the user edits the value in DevTools (Application tab) or types the query string. A plain cookie your server reads without verifying a signature is the same thing. OWASP lists "a cookie or hidden field manipulated to elevate privileges" as a textbook access control failure. The Stripe success page bypass is the paid-plan version of this bug.
5. A role column the user can write
This is the sneaky one, because it looks like you did it on the server. The AI tool adds role (or is_admin) to profiles, then writes the usual "users can update their own profile" policy:
-- BAD: the owner can update every column, including role
create policy "Users update own profile"
on public.profiles for update to authenticated
using ( (select auth.uid()) = id )
with check ( (select auth.uid()) = id );
Bypass: the user sends update profiles set role = 'admin' where id = <their own id> through the Supabase REST API with the public key and their own session. The policy passes, because it is their row. Now every RLS policy that trusts profiles.role trusts them. Supabase's user_metadata has the same problem: the RLS docs warn that it "can be updated by the authenticated user using the supabase.auth.update() function" and "is not a good place to store authorization data." Firebase has the twin bug when a role field lives on a user document the owner may update.
The common thread: the backend answers anyone
Every pattern above fails for the same reason. Your Supabase URL and publishable (anon) key are in the bundle by design, so anyone can query your tables directly. Your API routes are just URLs. Your Firebase config is public. If the table has no RLS, the route has no check, or the rule says allow read: if request.auth != null, the attacker never touches your admin page. The anon key vs service role key guide explains why the public key is fine only when RLS is right.
Fix it in Supabase: roles in app_metadata or an admin table
You have two good options. Both keep the role out of reach of the user.
Option A: a role in app_metadata
app_metadata (stored as raw_app_meta_data) "cannot be updated by the user," according to the Supabase RLS docs, and it travels in the user's JWT. Set it from a trusted place only: the SQL Editor, a migration, or server code that uses the secret key.
-- Run once in the SQL Editor (never from the browser)
update auth.users
set raw_app_meta_data = raw_app_meta_data || '{"role": "admin"}'::jsonb
where id = 'YOUR-USER-UUID';
Then read it in policies with auth.jwt():
create policy "Admins read all orders"
on public.orders for select
to authenticated
using ( ((select auth.jwt()) -> 'app_metadata' ->> 'role') = 'admin' );
Two things to know. A JWT is a snapshot: Supabase notes that a change to app_metadata is not reflected in auth.jwt() until the user's token is refreshed, so removing someone's admin role takes effect at their next refresh. And the Supabase Security Advisor has a lint (0015, RLS references user metadata) that flags policies reading user_metadata by mistake.
Option B: a separate admin table the client cannot write
Supabase recommends "RLS policies in combination with a dedicated table for handling user roles" over column-level tricks (column level security). Put it in a schema the API does not expose, and wrap the lookup in a security definer function, the pattern the RLS docs use for membership checks:
create schema if not exists private;
create table private.admins (
user_id uuid primary key references auth.users (id) on delete cascade
);
create function private.is_admin()
returns boolean
language sql
security definer
set search_path = ''
stable
as $$
select exists (
select 1 from private.admins where user_id = (select auth.uid())
);
$$;
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;
create policy "Admins read all orders"
on public.orders for select
to authenticated
using ( (select private.is_admin()) );
This version takes effect immediately when you add or remove a row, because it is looked up on each query instead of baked into the token. Keep private out of "Exposed schemas" in your API settings; the RLS docs warn that a security definer function in an exposed schema is callable over the Data API.
Option C: a custom claim from the access token hook
If you want a tidy user_role claim, Supabase's custom access token hook runs before each token is issued and can add claims from a roles table. The RBAC guide walks through a user_roles table, the hook, and an authorize() function for policies. Note the grants it uses: the roles table is readable by supabase_auth_admin and revoked from authenticated and anon, so users cannot grant themselves a role.
If the role is still in profiles
Move it out (option B). If you cannot yet, at least stop users writing it. Revoke the broad update grant and grant back only the columns users may edit:
revoke update on table public.profiles from authenticated;
grant update (display_name, avatar_url) on table public.profiles to authenticated;
Then make sure every table has RLS enabled. A table in an exposed schema without RLS "is readable and writable by any role with a grant on it," says Supabase, and the Security Advisor flags it as lint 0013. The Supabase RLS checklist goes table by table.
Admin actions that need the secret key (deleting users, reading across all tenants) belong in an Edge Function or server route that verifies the caller's JWT and checks the role before it touches the admin client. Supabase Edge Function auth has the full pattern.
Fix it in Next.js: check on the server, every time
The Next.js authentication guide splits checks into "optimistic" ones, fine for showing or hiding UI, and "secure" ones for sensitive data. It says Proxy (formerly Middleware) "should not be your only line of defense" and that most checks should run "as close as possible to your data source." The CVE-2025-29927 middleware bypass showed what happens when middleware is the only lock.
Put the check in a small server-only helper and call it everywhere. With Supabase Auth:
// lib/auth/require-admin.ts
import 'server-only';
import { createClient } from '@/lib/supabase/server';
export async function requireAdmin() {
const supabase = await createClient();
const { data } = await supabase.auth.getClaims(); // verifies the token
const claims = data?.claims;
if (!claims) return { ok: false as const, status: 401 };
if (claims.app_metadata?.role !== 'admin') return { ok: false as const, status: 403 };
return { ok: true as const, userId: claims.sub, supabase };
}
Route Handlers. Treat each one "with the same security considerations as public-facing API endpoints":
// app/api/admin/orders/route.ts
import { requireAdmin } from '@/lib/auth/require-admin';
export async function GET() {
const auth = await requireAdmin();
if (!auth.ok) return new Response(null, { status: auth.status });
const { data } = await auth.supabase.from('orders').select('id, total_cents, status');
return Response.json(data ?? []);
}
Server Components. Make the admin page a Server Component that checks before it fetches, so nothing is rendered or serialized for a non-admin:
// app/admin/page.tsx (no 'use client')
import { redirect } from 'next/navigation';
import { requireAdmin } from '@/lib/auth/require-admin';
export default async function AdminPage() {
const auth = await requireAdmin();
if (!auth.ok) redirect('/');
const { data: orders } = await auth.supabase.from('orders').select('id, total_cents, status');
return <OrdersTable orders={orders ?? []} />;
}
Next.js warns against relying on layouts for this: layouts do not re-render on navigation, and a layout that hides its children "does not stop them from running or from appearing in the RSC Payload." Check in the page or close to the data.
Server Actions. The Next.js data security guide is blunt: "A page-level authentication check does not extend to the Server Actions defined within it." Call requireAdmin() at the top of every admin action, because actions are reachable by direct POST. More in Next.js Server Actions security.
Do not over-send. Pass client components only the fields they render. The Bootlegger case is what happens when a page ships the full dataset and lets the UI decide what to show.
Fix it in Firebase: custom claims plus Security Rules
Firebase custom claims are set only with the Admin SDK ("only be set from a privileged server environment") and arrive on request.auth.token in rules:
// Run on a server or in a one-off admin script, never in the client
import { getAuth } from 'firebase-admin/auth';
await getAuth().setCustomUserClaims(uid, { admin: true });
// firestore.rules
match /orders/{orderId} {
allow read, write: if request.auth != null && request.auth.token.admin == true;
}
Firebase says you may read claims in the client to adjust the UI, but "backend access should always be enforced through the ID token after validating it," and claims "should not be sent directly to the backend." A user picks up a new claim on next sign-in, on token refresh, or when the client calls getIdToken(true).
If you keep a role on a user document instead (the rules and auth page shows a get() lookup for this), the owner must not be able to write that field. Use affectedKeys().hasOnly() to allow only safe fields on update, and block the field on create:
match /users/{uid} {
allow create: if request.auth.uid == uid && !('role' in request.resource.data);
allow update: if request.auth.uid == uid
&& request.resource.data.diff(resource.data).affectedKeys().hasOnly(['displayName', 'photoURL']);
}
Your own server code that uses the Admin SDK bypasses rules, so it needs the same verifyIdToken() and claim check. The Firebase security rules checklist covers the rest of the deployed rules.
Fix it in Clerk: publicMetadata, checked on the server
Clerk has three kinds of user metadata. publicMetadata "can be read by both the frontend and the backend, but can only be set on the backend." unsafeMetadata "can be modified directly from the frontend, which means malicious users could potentially tamper with these values." Roles go in publicMetadata.
Clerk's basic RBAC guide adds "metadata": "{{user.public_metadata}}" to the session token in the dashboard, then checks it on the server with auth():
// app/api/admin/stats/route.ts
import { auth } from '@clerk/nextjs/server';
export async function GET() {
const { userId, sessionClaims } = await auth();
if (!userId) return new Response(null, { status: 401 });
if (sessionClaims?.metadata?.role !== 'admin') return new Response(null, { status: 403 });
// ...admin-only work
}
The guide's own role-changing Server Action checks that the caller is an admin before calling updateUserMetadata(), which is the pattern to copy. If your data lives in Supabase with Clerk as the sign-in provider, RLS still has to enforce the role; a Clerk check in a React component does not reach your database. Keep the Clerk secret key server side (Clerk keys).
What about brute force on the login?
Briefly, because it is a different bug. A hardcoded client password cannot be throttled at all. A real auth provider can: Supabase Auth applies rate limits and supports CAPTCHA, and the OWASP Authentication Cheat Sheet covers brute force, credential stuffing, and password spraying. Turn on MFA for admin accounts. The Supabase Auth settings guide lists the switches.
Check your own app (no secrets, no console code)
Only test apps you own. None of these steps asks you to paste a key anywhere or run code in the browser console.
- Open a private window, signed out. Visit
/admin,/dashboard, and any other private URL directly. A login screen is expected. Any real data on screen is a bug. - View the page source of the login screen (right-click, View Page Source). Search it for something only an admin should see, like a customer's name or a store total. If it is there, you have the Bootlegger bug.
- Run the Live App Scanner on your deployed URL. It reaches your app the way a signed-out stranger would, flags secrets in the shipped JavaScript, maps the APIs and tables your app uses, and checks whether Supabase tables or Firebase paths answer the public key. It is read only.
- Run the repo scanner on your public GitHub repo to catch secret keys committed next to the code.
- Read your dashboards. In Supabase, open the Security Advisor and fix lints 0013 (RLS disabled) and 0015 (policy reads user_metadata), then review each table's policies on the Policies page. In Firebase, open the Rules tab for each product and use the Rules Playground to simulate an unauthenticated read and a non-admin write. In Clerk, open your user and confirm the role is under Public metadata, not Unsafe.
- Sign in as a second, normal user. Try the admin URLs and admin buttons. Expect a redirect for pages and a 401 or 403 for APIs. Then try editing your own profile and confirm nothing lets you change a role field.
- Search your code for
isAdmin,role ===,localStorage,user_metadata,unsafeMetadata,ADMIN_PASSWORD, and hardcoded email addresses, and confirm every hit is either UI-only or backed by a server check.
Checklist
- No admin data is sent to the browser before a server-side role check
- Every admin Route Handler, Server Action, and Edge Function checks the session and role itself
- Admin pages are Server Components that check before fetching; no auth check lives only in a layout or Proxy
- Roles live in Supabase
app_metadata, a server-written table, Firebase custom claims, or ClerkpublicMetadata - No RLS policy reads
user_metadata; no client code trustslocalStorage, query strings, or unsigned cookies for roles - Users cannot update
role,is_admin, or plan columns on their own row (separate table or column grants) - RLS enabled on every table in an exposed schema; admin policies use
app_metadataor anis_admin()function - Firestore rules check
request.auth.tokenclaims and block writes to role fields - No passwords, admin emails used as secrets, or secret keys in client code or
VITE_/NEXT_PUBLIC_variables - Admin accounts use MFA; login has rate limits or CAPTCHA
- Signed-out and second-user tests passed; Live App Scanner shows no readable tables
Then run the full ship checklist for a vibe-coded app before you share the URL.
Ask your AI tool to audit it
Paste this into Cursor, Claude Code, Windsurf, Lovable, or Bolt inside your project. It reads code only and asks the model not to repeat secret values.
Audit this repository for authorization that only happens in the browser.
1. List every place that checks a role, admin flag, password, or email to decide
access: search for isAdmin, is_admin, role, admin, localStorage, sessionStorage,
user_metadata, unsafeMetadata, ADMIN_PASSWORD, and hardcoded email addresses.
For each, say whether it runs in the browser ('use client', React/Vite components,
hooks) or on the server, and quote the file and line.
2. For every admin page, button, or feature, trace where its data comes from (API
route, Server Action, Server Component, Supabase query, Firebase call, Edge
Function) and show the server-side line that checks the caller's role. Flag any
data path with no server-side role check.
3. Supabase: list tables with RLS disabled, policies that read user_metadata, update
policies on tables that contain role, is_admin, or plan columns, and any
security definer function in an exposed schema.
4. Firebase: list rules that allow reads or writes with only request.auth != null,
and rules that let a user write a role field on their own document.
5. Next.js: flag auth checks that exist only in middleware/proxy or a layout, and
Server Actions that do not re-check the session and role.
6. Flag any password, secret key, or token in client code or in a VITE_ or
NEXT_PUBLIC_ variable. Report only the file, line, and variable name, never
the value.
Do not change any files yet. Give me a table of findings ordered by severity.
Then read the list yourself. Models miss checks hidden in helpers and often call a client-side redirect "protected." More prompts are in the security review prompt guide.
Sources
- OWASP Top 10:2025 A01 Broken Access Control
- OWASP ASVS 5.0: V8 Authorization (8.3.1, trusted service layer)
- OWASP Cheat Sheet: Authorization
- OWASP Cheat Sheet: Authentication (brute force, credential stuffing)
- MITRE CWE-602: Client-Side Enforcement of Server-Side Security
- MITRE CWE-603: Use of Client-Side Authentication
- MITRE CWE-798: Use of Hard-coded Credentials
- Supabase: Row Level Security (auth.jwt, user_metadata vs app_metadata, security definer)
- Supabase: Custom Claims and Role-based Access Control
- Supabase: Custom Access Token Hook
- Supabase: Column Level Security
- Supabase: Advisors (Security Advisor lints)
- Supabase: Auth rate limits
- Supabase: Bot detection (CAPTCHA)
- Next.js: Authentication guide (optimistic vs secure checks, Proxy, layouts, Route Handlers)
- Next.js: Data security guide (Server Actions, Data Access Layer)
- Firebase: Control access with custom claims and Security Rules
- Firebase: Security Rules and Firebase Authentication
- Firebase: Control access to specific fields (affectedKeys)
- Clerk: User metadata (public, private, unsafe)
- Clerk: Basic RBAC with metadata
- Clerk: auth() for the Next.js App Router
- MyBroadband: South African coffee franchise exposes company and private data in vibe-coded web application (Oct 16, 2026)
- MyBroadband: CEO of top South African coffee franchise responds after vibe-coded web application exposed sensitive data
- Quality Franchise Association: Bootlegger data leak exposes franchisee contact details (Oct 17, 2026)
Questions
Is it safe to check the user's role in the frontend?
Only for deciding what to draw. A role check in React, Vue, or plain JavaScript can show or hide a button, but it cannot protect data, because the person using the browser controls that code. The same check has to run on the server or in the database (Supabase RLS, Firebase Security Rules, a Next.js Route Handler or Server Action) before any admin data is returned or changed.
Can users edit Supabase user_metadata?
Yes. Supabase says user_metadata (raw_user_meta_data) can be updated by the signed-in user with supabase.auth.updateUser(), so it is not a place for roles. Store roles in app_metadata, which users cannot change, or in a table that only your server or a database admin can write, and read it from RLS policies.
Is Next.js middleware enough to protect an admin page?
No. The Next.js authentication guide calls Proxy (the file older versions called Middleware) a place for optimistic checks and says it should not be your only line of defense. Check the session and role again in the Server Component that renders the page, in every Server Action, and in every Route Handler, as close to the data as you can.
If nobody knows the admin URL, is it safe?
No. URLs leak through screenshots, browser history, shared links, and the JavaScript bundle itself, which often lists every route. In the Bootlegger case reported by MyBroadband in October 2026, the dashboard address was spotted in photos employees posted, and the data came down with the login page. Assume every URL is public and protect the data behind it.
Can I put an admin password in my React or Vite app?
No. Anything in client code, including a VITE_ or NEXT_PUBLIC_ variable, is shipped to every visitor and can be read in the built files. It also cannot be rate limited, and it protects nothing if the API or database answers without it. Use a real sign-in from your auth provider and an admin role checked on the server.