How to secure your app's admin panel
How to protect an app's admin panel: roles checked on the server, where to store them in Supabase, two-factor for admins, audit logs and least privilege.
Leer en español →
A secure admin panel is one where every admin action is authorised on the server or in the database, based on a role the user cannot change themselves, and where admin accounts need a second factor to sign in. Hiding the admin page, using an unguessable URL or checking an isAdmin flag in the browser does not protect anything, because the data and actions behind the page are still reachable directly. Add an audit log of admin actions and give each person only the access their job needs.
This guide covers where the role should live, how to enforce it in Supabase or a custom backend, why admins need two-factor authentication, what to log, and a short checklist for reviewing the panel you already have.
Why is the admin panel such a common weak spot?
Because it holds the most power and gets the least testing. Ordinary users exercise every screen of the main app; the admin panel is used by one or two people who already have access, so nobody notices when its protection is only cosmetic.
In apps built with Lovable, Bolt, Cursor and similar tools, the usual patterns are:
- The admin route is hidden in the menu, but anyone signed in can open
/admindirectly. - The page checks the role in React (
if (user.role === 'admin')), while the database tables it reads are open to every signed-in user. - The role is stored somewhere the user can edit, such as their own profile row or Supabase
user_metadata. - Admin actions call the database with the secret (
service_role) key from the browser, which means that key is public.
Where should the admin role be stored?
Somewhere only your server can write. In Supabase you have two sound options.
| Option | Who can change it | Good for |
|---|---|---|
app_metadata on the user | Only server code with the secret key | A few fixed roles (admin, staff, user) |
A user_roles table with strict policies | Only admins or server code | Many roles, roles per organization, instant changes |
user_metadata on the user | The user themselves | Display name, preferences. Never permissions |
A role column in profiles the user can update | The user themselves | Nothing related to access |
Supabase's own documentation warns that user_metadata can be modified by the signed-in user and should not be used in security policies. Set app_metadata from a server function instead:
// Server side only, using the secret key
await supabaseAdmin.auth.admin.updateUserById(userId, {
app_metadata: { role: 'admin' },
});
The role then appears in the user's token after their session refreshes. If you need a role change or removal to apply immediately, a roles table read by your policies is the better choice.
How do I enforce admin access in the database?
Every table the admin panel reads or writes needs a policy that allows the admin role explicitly, separate from the policies for ordinary users. For a role stored in app_metadata:
create policy "Admins can read all orders"
on public.orders for select
to authenticated
using ((select auth.jwt() -> 'app_metadata' ->> 'role') = 'admin');
Add equivalent policies for the updates and deletes admins really need, and nothing more. If admins need bulk operations or data that ordinary policies shouldn't touch, put those actions in server functions that verify the caller's role first and only then use the secret key. Our guide to user roles and an admin panel in a Bolt app shows the full setup step by step.
In a custom backend the rule is the same: a middleware that reads the role from the verified session and rejects the request before any admin handler runs.
Should admins be required to use two-factor authentication?
Yes. An admin account is the most valuable account in your app, and a reused or phished password is the most common way in. Supabase Auth supports TOTP (authenticator app) factors on every project, and you can require a second factor at the database level with a restrictive policy that checks the token's assurance level:
create policy "Admin tables need MFA"
on public.orders
as restrictive
to authenticated
using ((select auth.jwt() ->> 'aal') = 'aal2');
A restrictive policy like this one applies to everyone who reaches the table, so on a table ordinary users also read, limit it to admins or put admin-only data in its own tables. Our guide to adding two-factor authentication covers enrolment and recovery.
What should the admin panel log?
Enough to answer "who did what, to which record, and when" after the fact. At minimum:
- Sign-ins to admin accounts, including failures.
- Role changes, account deletions, refunds, plan changes and data exports.
- Any time an admin views or acts "as" another user.
Write these to a table that admins can insert into but not edit or delete, or to an external logging service. Review it from time to time, and set an alert for unusual events such as a new admin being created. Our guide on monitoring and error tracking suggests simple tools.
How do I apply least privilege?
Not everyone who uses the panel needs everything in it.
- Split the role into what people actually do: support (read accounts, resend emails), billing (refunds), owner (everything, including roles).
- Give dangerous actions (deleting users, exporting all data, changing roles) to the smallest group possible.
- Ask for confirmation, or a second person, on irreversible actions.
- Remove access the day someone leaves or a contractor finishes. Keep a list of who has admin access and review it every few months.
- Never share one admin login between people; individual accounts are what make the log useful.
How do I review the admin panel I already have?
A short test you can run yourself:
- Sign in as an ordinary test user and open the admin URL directly. You should be refused by the server, not just redirected by the page.
- With that same user, repeat a request the admin panel makes (copy it from the browser's Network tab). It must fail.
- Search your frontend code for
service_role,sb_secret_andsupabaseAdmin. None should appear in code the browser loads. - Check where the role is stored, and try changing it from the ordinary account (edit your own profile). It must not stick.
- Sign in as an admin without a second factor. If that works, enforce it.
Quick answers
Is a secret admin URL enough? No. URLs leak through browser history, logs and screenshots. Access must be checked on every request.
Can I build the admin panel as a separate app? Yes, and it can be a good idea, but the same rules apply: server-side role checks and no secret keys in its browser code.
What about tools like Supabase Studio for admin work? Dashboard access is powerful. Limit who has it, use individual accounts with two-factor, and don't treat it as a substitute for a proper admin panel for non-technical staff.
Does the role update instantly when I change it? With app_metadata, only after the user's token refreshes. Use a roles table if you need instant removal.
Want someone to check it?
The free health check shows what's exposed from outside, our Supabase security review goes through roles, policies and admin actions in the code, and you can tell us about your setup.


