An admin panel security checklist names the controls that keep an ordinary account from doing admin work: every admin route and operation checks a verified role on the server, and the role lives where the user cannot write it. Of 21 third-party apps examined in June and July 2026, eleven let unauthenticated endpoints perform privileged actions.

The admin panel security checklist: 12 checks for an app you own

An admin panel security checklist for a small app has 12 checks in three groups: who may act, how admins prove who they are, and what the panel leaves behind. The first five decide everything else, because they put the role check on the server for every admin route and operation.

The 21 behind that opening number are the third-party apps I audited in June and July 2026: 11 public vibe-coded apps audited exhaustively across all 12 pillars, and 10 disjoint held-out apps audited blind. I chose them; they are not a random sample, and 11 of 21 is not a rate for AI-built apps in general.

By admin panel I mean the routes and operations of your own app that change other people’s data: the user list, role changes, refunds, deletes, plan overrides, impersonation and exports. A Windows administrator account, a Google Workspace tenant and a vendor’s Admin API are different subjects. Everything below is the admin slice of the authentication checklist.

Checks 1 to 5, who may act:

  • every admin page is refused on the server for a non-admin, not just hidden from the menu;
  • every admin operation checks the role itself: each API route, server action, RPC and edge function;
  • the role is read from a place the user cannot write;
  • sign-up and profile-update endpoints ignore any role field the client sends;
  • new admin routes are protected by where they are registered, so access is denied unless granted.

The fifth is my working rule, and it is the same idea as OWASP’s deny-by-default line quoted in the section on restricting routes.

Checks 6 to 8, how admins prove who they are:

  • every owner and admin account has a second factor;
  • admin sessions expire and can be revoked from one place;
  • destructive operations ask for a fresh sign-in.

For the sixth, the steps to implement two-factor authentication apply to every admin account. The seventh, a list of every signed-in device with one place to end them, is the subject of what is active session management, and the eighth is my working rule.

Checks 9 to 12, what the panel leaves behind:

  • role changes, deletions, refunds and plan overrides are written to an audit log;
  • support actions such as granting free access are named operations with a reason, never a row edit;
  • service keys and admin secrets stay on the server;
  • an ordinary-account test runs against every admin operation.

What each log row should hold follows audit logging best practices.

In the Production Hardening Sprint, deliverable 1.5 is written as “Restrict admin routes and operations to explicit, verified roles.”

What goes wrong without it

The table is my reading of the four usual failures, each with the check from the list that closes it.

What the owner seesWhat is trueThe check that closes it
The admin page loads for a normal userThe server sent the pageChecks 1 and 3
The admin page is hidden, but the API answersThe endpoint has no role checkCheck 2
A user has a role nobody gave themSign-up or a profile update wrote it from the clientChecks 3 and 4
A support shortcut became a holeA leftover “make pro” route, or a direct edit to the rowChecks 10 and 12

The same audits, a set I picked rather than drew at random, put numbers on these rows. Beside the 11 from the opening, 10 of the 21 third-party apps trusted the client: the server accepted whatever the browser asserted. In 7 of the 21 there were confirmed cross-user or cross-tenant authorization failures, where a logged-in user could read or write another customer’s data. Among the third-party apps I audited, the Authorization pillar averages 42.1 out of 100, scored on 14 of the 21 because pillars that did not apply were left out, not zeroed.

One app I audited had an endpoint where a single web request drops the entire production database. That story is told in full in the API security checklist.

My reading of why this keeps happening: the generated admin page and the generated API are separate pieces of code, and a prompt that asked for “an admin page” got a page. Outside the admin area, the same failure goes by its general name, broken access control.

A normal user can access admin pages

A normal user who can access admin pages was sent them by the server. A redirect in the browser runs only after the server has sent the page, and a hidden link leaves the URL working. The server has to refuse the page and its data, with a 403 or a redirect, before anything is sent.

It shows up in two versions. In one, the link is gone from the menu, but typing /admin into the address bar still works. In the other, a route guard in React Router or a client-side check in Next.js redirects the user, but only after the page’s code has reached the browser. A guard in the browser is display logic. Next.js’s authentication guide says that “client-side UI restrictions alone are not sufficient for security.”

What the server does instead is refuse the page request and the data request before any admin data leaves it, with a 403 or a redirect to sign-in. A noindex tag and a hard-to-guess path are not controls either, in my rule set: they only make the page harder to find, and anyone holding the URL still gets in. None of this is the Windows question of an account with administrator rights.

A regular user can call admin API endpoints

Any signed-in user can call admin API endpoints when the page is guarded and the endpoint is not: an ordinary account just replays the request the admin page sends. OWASP files it under API5:2023, Broken Function Level Authorization. The fix is a role check inside every operation.

The page in front can be fully protected while the endpoint behind it answers anyone with a session. OWASP API5:2023 Broken Function Level Authorization asks the question in these words: “Can a regular user access administrative endpoints?” The wider category, A01:2025 Broken Access Control, sits first in OWASP’s Top 10:2025. By admin API I mean your own privileged endpoints, not a vendor’s Admin API product.

phpMyFAQ’s advisory in the GitHub Advisory Database (GHSA-jrc5-w569-h7h5, CVE-2026-45009) says several backend (admin-api) endpoints only checked whether the caller was logged in, not whether the caller had backend or administrative privileges. In the reporter’s local reproduction, a signed-out request was rejected with 401, while a regular user account received a successful response from a backend management endpoint. The advisory says the issue does not appear to give direct write access in the affected paths that were confirmed, so it treats it as a backend information disclosure and privilege boundary failure rather than full admin compromise, rated Medium. Among its four remediation steps are explicit permission checks on backend endpoints intended for administrators only, and regression tests that log in as a low-privileged user and verify that backend routes return 403 or 401 where appropriate; version 4.1.1 is affected and 4.1.2 is the first patched release.

The lesson I take from it: a login check answers who is calling, not whether they may do admin work, so every admin endpoint has to ask the second question on the server.

How to restrict admin routes by role

Admin routes are restricted by role with one server-side check that every admin route and operation calls, reading the role from a place the user cannot write. Register it once for the whole admin route group, so a new route is protected by where it lives. The browser guard only decides what to display.

The rule has three parts. First, one role check function, called on the server by every admin route and operation, that reads the role from a server-side source. Second, that function is registered once for the whole admin route group, so a route added next month is protected because of where it sits, not because someone remembered. Third, the guard in the browser stays, as display. OWASP’s prevention advice for API5 says the same thing from the other end: “The enforcement mechanism(s) should deny all access by default, requiring explicit grants to specific roles for access to every function.”

Where the role may live

Two rows below come from Supabase’s row-level security guide; the verdicts on the other rows are my rules.

Where the role is storedCan the user write it?Verdict
A role column or roles table that the server readsNot if no policy or endpoint lets themSafe
Supabase raw_app_meta_data (app_metadata in the token)No: it “cannot be updated by the user”Safe: “a good place to store authorization data”
Supabase raw_user_meta_data (user_metadata in the token)Yes: it “can be updated by the authenticated user”Unsafe: “not a good place to store authorization data”
A custom claim in a signed token, set by the server at sign-inNoSafe to read, but it keeps the old role until the token is replaced
localStorage, or a cookie the client setsYesUnsafe
A field in the request body, or a request headerYesUnsafe
An email-domain check in the front endYes, the check runs in their browserUnsafe

The token row is my reading, and token lifetime is its own subject under JWT security. How a policy that reads user_metadata hands a user admin access, down to the one call that does it, is in what a Supabase RLS policy cannot decide. Deciding who gets which role in the first place is the role model, and RBAC examples for a small SaaS are the place to start it.

Next.js, Express and other server frameworks

The pattern on any server framework is a route group with one guard in front of it.

  1. Put every admin route in one group: a router, a folder or a prefix.
  2. Attach the role check to the group, so it runs before any route in it.
  3. Read the role from your own records, set by your session code, never from the request.
  4. Answer 401 with no session and 403 with the wrong role.

In Express that group is a router: Express’s router-level middleware is loaded with router.use() and router.METHOD(); one loaded with router.use() and no mount path runs for every request to the router, and the router is mounted on a path with app.use().

const adminRouter = express.Router();

// req.user is set earlier by your session middleware, from your own records.
function requireRole(role) {
  return (req, res, next) => {
    if (!req.user) return res.sendStatus(401);
    if (req.user.role !== role) return res.sendStatus(403);
    next();
  };
}

adminRouter.use(requireRole('admin')); // runs before every route on this router
adminRouter.get('/users', listUsers);
app.use('/admin', adminRouter);

In Next.js the check goes inside each Server Action, Route Handler and Server Component that returns admin data. The Next.js authentication guide says to treat Server Actions and Route Handlers “with the same security considerations as public-facing API endpoints”, warns that checks in Layouts “don’t re-render on navigation”, and says Proxy (the proxy.ts file) can be useful for initial checks but “should not be your only line of defense in protecting your data”.

Framework versions matter too. Of the 26 apps in my June and July 2026 audits, again a chosen set rather than a sample, 9 ran a framework version with a publicly known, reachable RCE or auth bypass, and the fix was often a one-line version bump.

My working rule: a check that lives in one layer fails when that layer does, so the role check also sits beside the data. Laravel and Django follow the same shape, with one role check attached through middleware or a decorator in front of every admin route.

Supabase and builder-made apps

For a Lovable, Bolt or v0 app on Supabase, the admin check is a database matter. Policies on admin-only tables test the caller’s role from app_metadata or a roles table, never user_metadata, as the role table above sets out. The service_role role has “Full access. It bypasses RLS, so keep it server-side.” Supabase’s guide adds a condition: “A secret key bypasses RLS only when the request carries no user access token”, and it calls the JWT-based service_role key “a legacy alternative”.

My working rule follows from that: an edge function that uses a secret key checks the caller’s token and role before it acts, because row-level security will not do it for a call made with that key and no user access token. The two practices that go deeper are in Supabase RLS best practices. In the same audits, 6 of the 21 third-party apps shipped a real secret, and 9 of the 21 had row-level security gaps.

You can check both from the dashboard before touching a terminal:

  1. Open Database > Policies in the Supabase Dashboard and read the policies on every admin-only table.
  2. Open the SQL Editor and run select raw_app_meta_data from auth.users where id = '<test user id>'; for a test admin and a test ordinary user; if the role lives in a roles table, read that table for the same two ids instead.
  3. Confirm each policy reads the role from where step 2 shows it, and not from raw_user_meta_data.

Keeping one customer’s rows away from another’s is a separate control, multi-tenant data isolation.

How to grant free access to a customer account, safely

Granting free access to a customer account should be one named admin operation: it takes the account, the plan, an end date and a reason, checks the admin role on the server, writes the entitlement the backend reads, and records who did it. A direct edit to the row leaves no trace.

The need is real: a comped plan for a friendly customer, an extended trial, a partner account. The unsafe versions are common in my reading: a direct edit to the subscription row, a boolean on the user record that the client can set, or a “make pro” endpoint left over from testing with no role check at all.

Built properly, it is an admin-only endpoint or server action whose inputs are those four fields. The entitlement goes where the backend already reads plan limits (how to enforce plan limits on the backend is a separate topic), and the audit entry names the admin, the account and the reason. If billing runs on Stripe, a coupon keeps the discount inside billing. Per Stripe’s coupon documentation, duration defaults to once, which applies only to the first invoice; repeating with duration_in_months covers a set number of months; and forever applies it to all invoices indefinitely. Stripe’s API reference allows a percent_off up to and including 100, so a coupon can take the whole amount.

Impersonation (“log in as customer”) follows the same pattern, as my rule: one named operation, a banner visible for the whole session, and a log line. It is not built here.

How to verify it: test every admin endpoint with an ordinary account

Admin protection is verified by replaying every admin operation three ways: as an admin, as an ordinary account and signed out. The last two must be refused, with no admin data returned and the database row unchanged, whatever status code the refusal carries. The filled matrix, dated, is the evidence.

The point of the steps below is to test each admin endpoint with an ordinary account and keep proof of the refusal. Only test systems you own, on staging or with seeded data.

  1. 01 List every admin operation: read the admin pages' network calls and the routes folder. Evidence: the list, which becomes the first column of the matrix.
  2. 02 Create two test accounts on staging, one admin and one ordinary, in a second tenant if the app has tenants. Evidence: both account ids.
  3. 03 As the admin, run each operation and save the request. Evidence: the saved requests and the admin's responses.
  4. 04 Replay each saved request with the ordinary account's credentials: swap the admin's session cookie or Authorization header for the ordinary account's, then replay once more with neither. A replay that still carries the admin's credentials cannot fail. Evidence: each status code.
  5. 05 Expect both replays to be refused, with no admin data in the response and no change in the database. Check the row, not just the status code. Evidence: the response and the row before and after.
  6. 06 Try to raise your own role through sign-up and profile update, and confirm the role field is ignored. Evidence: the role value read on the server after the call.

What “refused” looks like depends on where the guard lives. A route the server guards answers the ordinary account with 403, which RFC 9110 defines as a server that “understood the request but refuses to fulfill it”, and a signed-out caller with 401, a request that “lacks valid authentication credentials”. RFC 9110 also lets a server answer 404 instead of 403 to hide that the resource exists. A table guarded only by a database policy can answer with no rows and no error, because in PostgreSQL’s words, rows for which the policy expression does not return true “will not be processed”. So the data decides the check, not the status code.

For the browser-only version of step 4, open the Network panel in Chrome DevTools as the admin, right-click the admin request, and choose Copy > Copy as fetch. Which headers the copied call carries is not stated in Chrome’s DevTools docs, so read it before you use it: delete any Authorization or cookie header, or swap it for the ordinary account’s token. Then paste it into the console of a tab where the ordinary account is signed in.

The expected results in the matrix are my rules, with five example operations filled in.

OperationAs adminAs ordinary accountSigned out
List usersSucceedsRefused, no user data in the responseRefused
Change roleSucceeds, role changesRefused, role unchangedRefused
Delete userSucceeds, row removedRefused, row still thereRefused
RefundSucceedsRefused, no refund createdRefused
Grant planSucceeds, entitlement writtenRefused, plan unchangedRefused

Then keep it. My working rule is to turn the same matrix into an automated test that runs on every pull request, so a guard removed from a listed operation fails the build, and each new admin operation joins the matrix in the pull request that adds it. The test cannot see a route nobody listed. The phpMyFAQ advisory’s remediation steps include a regression test of the same kind. The evidence to keep is the filled matrix with its date and the commit id, compared against your written role matrix.

In the sprint, deliverable 1.5 is verified this way: “Test administrative operations using both authorized and ordinary accounts.”

Where the sprint does this

We record the result of that 1.5 test, with its evidence, in the production readiness report, deliverable 13.1, which accounts for all 123 IDs, keeps failures visible until resolved and explains genuine non-applicable items. Formal third-party certifications and independent audit opinions are separate from these engineering deliverables. Building new product features or modules is outside the sprint. The admin and role work sits in area 1 of the published scope.

Common questions about admin access

What is the difference between an admin user and a normal user?

In your own app, the difference is one value the server reads, the role, and the set of operations that value unlocks. Nothing else about the account differs, in my reading: the same sign-in, the same kind of session, the same tables underneath.

How to check if a user has admin access?

Read the role where the server reads it, in your roles table or in the user’s app_metadata on Supabase, never in what the interface shows. On Supabase, if the role is in app_metadata, read the raw_app_meta_data column of auth.users; the SQL Editor can read it for one user id.

What are the risks of admin access?

Whoever holds an admin session can run every operation on the admin list, which is why checks 6 to 9 exist. The same holds for giving an AI agent admin access: the agent can do whatever that session allows.

What is an admin API?

An admin API is the set of privileged endpoints of your own app: the routes that list users, change roles, issue refunds or delete records. Vendor products with that name, such as Duo’s Admin API or Power BI’s admin APIs, are a different thing.

How do I give admin access to a user?

Give it through one admin-only operation that changes the role on the server and writes an audit entry, never through a client call users could make for themselves. If the app reads the role from a signed token, my reading is that the old token keeps the old role until it is replaced, which is a question of token expiry.