Skip to content

Guides · VibeVigil

How to Protect Admin Routes and Roles in a Lovable, Bolt or Cursor App

Why hiding /admin in the UI is not enough, how to enforce admin roles with Supabase RLS and Edge Functions, how to test that a normal user cannot open admin pages or call admin APIs, and a ready-to-paste fix prompt for your coding agent.

Not sure your app is safe? Run a free scan first.
VibeVigil checks your live app for exposed keys, Supabase tables without RLS, missing security headers, SSL problems and downtime. Every issue comes with a ready-to-paste prompt for your coding agent.
Scan my app for free → (only scan apps you own)

Your Lovable, Bolt, v0 or Cursor app has an /admin page. The menu only shows it to you. You feel safe.

But the browser is not a lock. Anyone who knows the URL can type it. Anyone who can call your API can try the same admin actions. If the database and server do not check the role, a normal user can reach admin data.

This guide shows how to spot that gap, enforce roles on the server, and test it yourself. Only test apps you own.

A public or outside scan (including VibeVigil's free scan) checks what is visible from the outside — exposed keys, missing RLS, headers, SSL and uptime. It cannot replace the logged-in role tests below. You still need two accounts and the checks in Step 5.

The short answer

  1. UI checks are not enough. Hiding a link or redirecting in React does not stop a direct URL, a Network replay, or a Supabase call.
  2. Put the role on the server. Store admin status where the user cannot edit it (app_metadata, or a roles table). Enforce it with Supabase Row Level Security and in Edge Functions.
  3. Never trust user_metadata for roles. Users can update that themselves. Use app_metadata or a table your policies read. (Supabase: RLS — auth.jwt())
  4. Test as a normal user. Log in as a non-admin and try /admin, admin API calls, and direct table reads. You should get blocked every time.
  5. A public scan does not replace those tests. Outside scanners cannot log in as your users or check role gates. Run the two-account tests yourself.

Why vibe-coded apps get this wrong

AI coding tools are good at screens. They often:

  • Check user.role === 'admin' only in a React component or route guard.
  • Store role: 'admin' in user_metadata (editable by the signed-in user).
  • Add an /admin page with no RLS policies that limit admin tables.
  • Add a new admin-only permissive policy but leave an older, looser permissive one in place (Postgres ORs them together).
  • Build an Edge Function that trusts a role field from the request body, or that reads JWT claims without verifying the token.
  • Ship a service_role / secret key in the browser so the client bypasses RLS entirely.

Hiding the page is fine for UX. It is not authorization.

Step 1: Find how your app decides "admin"

Search your project for:

/admin
isAdmin
role === 'admin'
user_metadata
app_metadata
AdminRoute
ProtectedRoute

Ask three questions:

QuestionSafe answer
Where is the role stored?app_metadata, or a profiles / user_roles table the user cannot update
Who can change it?Only you / a trusted server path (Dashboard SQL, Edge Function with secret key)
What blocks a non-admin?RLS policies and server checks — not only a UI redirect

If the only check is in the frontend, keep going. You need a server fix.

Step 2: Store the role where users cannot edit it

Supabase puts two metadata bags on each user:

  • user_metadata / raw_user_meta_data — the signed-in user can change this. Do not put admin flags here.
  • app_metadata / raw_app_meta_data — the user cannot change this. Good place for authorization data.

(Supabase: RLS helper auth.jwt())

Two solid patterns:

Pattern A: Role in app_metadata

Set it only from the Dashboard or server code that uses a secret key. Then policies can read:

(select auth.jwt() -> 'app_metadata' ->> 'role') = 'admin'

Caveat: JWTs can be stale until refresh. After you demote someone, they may keep the old claim until their token refreshes. (Supabase: RLS — auth.jwt() caution)

Pattern B: Roles table + custom claims (recommended for real apps)

Keep roles in a table, then use a Custom Access Token Auth Hook so the JWT carries the role for RLS. Supabase documents this as Custom Claims & Role-based Access Control (RBAC).

For checks that must be live right away (for example, after you remove admin), prefer a SECURITY DEFINER function that reads the roles table, as shown in the RLS security definer section.

Step 3: Enforce admin in RLS (not only in the UI)

Enable RLS on every admin-facing table. Then add policies that require the admin role. Example shape (adapt names to your schema):

alter table public.admin_notes enable row level security;

revoke all on table public.admin_notes from anon, authenticated;
grant select, insert, update, delete on table public.admin_notes to authenticated;

create policy "Admins can manage admin_notes"
on public.admin_notes
for all
to authenticated
using (
  coalesce((select auth.jwt() -> 'app_metadata' ->> 'role'), '') = 'admin'
)
with check (
  coalesce((select auth.jwt() -> 'app_metadata' ->> 'role'), '') = 'admin'
);

Prefer separate policies per operation (select / insert / update / delete) in production — Supabase recommends that style in the RLS guide.

Adding a stricter permissive policy does not remove old ones

This trips people up. In Postgres RLS, permissive policies on the same table and command are combined with OR. If you add a strict "admins only" policy but an older policy still says using (true) (or anything else that lets a normal user through), the old one still opens the door.

So:

  1. List every policy on each admin-facing table before you trust a new one. In the Supabase Dashboard: Authentication is not enough — open the table → Policies. Or in SQL:
select schemaname, tablename, policyname, permissive, roles, cmd, qual, with_check
from pg_policies
where tablename = 'admin_notes'  -- change to your table
order by policyname;
  1. Audit what each one allows. Drop or rewrite any permissive policy that lets non-admins in (using (true), overly broad ORs, policies meant for testing).
  2. Then keep (or add) the admin-only policy.

Also lock down tables that admins can see all rows of (users, orders, tickets). A normal user policy should still be auth.uid() = user_id. An admin override is an extra OR (or a separate policy), not a policy of true for everyone.

If RLS is off or you are unsure, start with How to Check if Supabase RLS Is Enabled.

Step 4: Protect admin APIs and Edge Functions

If /admin calls an Edge Function or server route:

  1. Require a valid user JWT. Do not turn off verify_jwt for user-facing admin functions. (Supabase: Securing Edge Functions)
  2. Verify the JWT before you trust any claims (role, user id, anything). Check the signature and that the token is still valid (not expired; issuer / audience match your project). Prefer supabase.auth.getUser(jwt) (or the equivalent server helper), which talks to Auth and validates the token. Do not only base64-decode the payload or read role from an unverified JWT — anyone can forge that.
  3. Inside the function, read the role from the verified user / app_metadata (or look it up in the roles table). Return 403 if the caller is not admin.
  4. Never take isAdmin: true or role: 'admin' from the request body as truth.
  5. Keep the Supabase secret / service_role key on the server only. If the browser has it, RLS does not protect you.

UI route guards (React Router, Next.js middleware that only redirects) are still useful so admins get a clean UX — but they are a second layer, not the first.

Step 5: Test that a normal user is blocked

Use two accounts on an app you own: one admin, one normal user. Two browsers (or one normal + one private window) help.

A public scan cannot do these checks for you. They need real sessions for both roles.

Test A: Open /admin while logged in as a normal user

  1. Log in as the normal user.
  2. Paste https://your-app.com/admin (and any other admin paths) into the address bar.

Good: redirect, blank page, or "no access" — and Network shows no admin data. Bad: the admin UI loads with real data.

Test B: Call admin APIs as the normal user

Copying an admin's request from DevTools often keeps the admin Authorization header and cookies. If you paste that as-is into another window, you may still be acting as the admin — so a "pass" means nothing.

Do this instead:

  1. As admin, open DevTools → Network, use an admin action, and note the URL, method and body (not the auth headers).
  2. Switch to the normal user's browser (or private window) where that user is logged in.
  3. Confirm who you are before you trust any result. For example, call your auth user endpoint (Supabase: supabase.auth.getUser() / the Auth /user endpoint) and check the user id / email matches the normal account — not the admin.
  4. Rebuild the request with the normal user's session only: their cookies and their Authorization: Bearer <normal-user-access-token>. Do not reuse the admin token or admin cookies from the copied fetch.
  5. Send the request.

Good: 401 / 403 / empty, while still logged in as the normal user. Bad: the action succeeds or admin rows come back under the normal user's session.

Test C: Query Supabase directly as the normal user

With the public anon/publishable key (normal for Supabase) and the normal user's session, try selecting from admin tables or listing all users.

Good: error or zero rows. Bad: admin notes, every user's email, or other privileged rows.

This is the same spirit as Can One User See Another User's Data? — here the "other user" is every user, via an admin path.

Test D: Logged out

Open /admin and replay an admin request with no session.

Good: login wall or 401. Bad: any admin data.

Common Lovable / Bolt / Cursor patterns (and fixes)

What the agent builtRiskFix
<AdminOnly>{children}</AdminOnly> that only hides UIDirect URL / API still worksKeep the UI guard; add RLS + server checks
role in user_metadataUser can self-promoteMove role to app_metadata or a locked roles table
Admin table with RLS off or using (true)Any logged-in (or anon) client can read itEnable RLS; admin-only policies
New admin-only permissive policy + old looser permissive policy left onPostgres ORs permissive policies — the old one still lets people inList pg_policies (or Dashboard Policies); drop or fix the loose ones
Edge Function trusts body role or decodes JWT without verifyAnyone can send admin or forge claimsVerify JWT (e.g. getUser); read role from verified user / DB only
Secret key in VITE_ / NEXT_PUBLIC_Client bypasses RLSMove secret to Edge Function / server env

Paste this prompt into your coding agent

Works in Lovable, Bolt, v0, Cursor or Claude Code:

Protect admin routes and roles in this app. Do not delete user data.

1. Find every admin page, route guard, and "isAdmin" / role check. List which checks run only in the UI vs on the server / database.
2. Stop storing authorization in user_metadata. Use app_metadata or a user_roles / profiles.role table that normal users cannot update via RLS.
3. Enable RLS on every admin-facing table. Add policies so only admins can select/insert/update/delete those rows. Use (select auth.uid()) and auth.jwt() -> 'app_metadata' (or a SECURITY DEFINER is_admin() that reads the roles table). Follow Supabase RLS docs.
4. Important: creating a new admin-only permissive policy does NOT remove older permissive policies. In Postgres, permissive policies combine with OR, so one old using (true) (or other broad) policy still lets non-admins in. For each admin-facing table, list all policies (query pg_policies or check the Supabase Dashboard Policies tab), audit them, and drop or fix any permissive policy that allows non-admins. Only then keep/add the admin-only policy.
5. For admin Edge Functions / API routes: require a valid user JWT (do not disable verify_jwt for user-facing admin functions). Verify the JWT signature and validity (expiry, issuer/audience) before trusting any claims such as role — prefer supabase.auth.getUser(jwt) or a proper JWT verify. Never only base64-decode the token. Never trust role from the request body. Never put the Supabase secret/service_role key in the browser. Return 403 if the verified caller is not admin.
6. Keep UI redirects for /admin as UX only — they are not the real lock.
7. Give me a short test plan with two accounts (admin + normal user):
   - Normal user opens /admin.
   - Replay an admin API call using ONLY the normal user's session: do not reuse Authorization headers or cookies from an admin DevTools "copy as fetch". Confirm the logged-in user id (e.g. auth getUser) is the normal account before judging the result.
   - Query admin tables with the anon key + the normal user's session.
   Expected result each time: blocked.
8. Remind me that a public/outside scan cannot replace these logged-in role tests.
9. Explain each change in plain English.

FAQ

I redirect non-admins away from /admin. Is that enough? No. Redirects are UX. Enforce the role in RLS and in any admin API.

Can I put role: 'admin' in user_metadata? No. Authenticated users can update user_metadata. Use app_metadata or a server-owned roles table. (Supabase: RLS — auth.jwt())

I added an "admins only" policy. Why can normal users still read the table? A new admin-only permissive policy does not remove older ones. Permissive policies are ORed together. List every policy on that table (pg_policies or the Dashboard) and drop or fix any permissive policy that still allows non-admins.

Do I need custom claims? For a single admin flag, app_metadata.role or a roles table may be enough. For richer RBAC, follow Custom Claims & RBAC.

My admin page is blank for normal users but Network still returns data. The UI is hiding it; the API is not. Fix RLS and the server handler.

Is a free outside scan enough to prove admin routes are locked? No. A public scan (including VibeVigil) checks what is visible without your users' logins. It does not run the two-account role tests. Use the scan for outside issues, then still run Tests A–D yourself on an app you own.

Want a second pass on the live site? Run a free VibeVigil scan → (only scan apps you own)

Sources

  • Supabase docs, Row Level Security: https://supabase.com/docs/guides/database/postgres/row-level-security
  • Supabase docs, Custom Claims & Role-based Access Control (RBAC): https://supabase.com/docs/guides/api/custom-claims-and-role-based-access-control-rbac
  • Supabase docs, Custom Access Token Hook: https://supabase.com/docs/guides/auth/auth-hooks/custom-access-token-hook
  • Supabase docs, Securing Edge Functions: https://supabase.com/docs/guides/functions/auth
  • Related guide: How to Check if Supabase RLS Is Enabled
  • Related guide: Can One User See Another User's Data?

Scan your app free. It checks the public page of a site you own. No account.

How to Protect Admin Routes and Roles in a Lovable, Bolt or Cursor App · VibeVigil