An admin panel is the most privileged surface in your application. It reads everyone’s data and usually modifies it. Which makes it the one place where the standard vibe coding trade — ship fast, review later — is a bad idea.
The build is straightforward. The access control is the whole job.
The Mistake This Article Exists For
Ask an AI for an admin panel and you’ll get something like this:
{user.role === 'admin' && <AdminNav />} // hides the link
The nav is hidden. The route still works. The API still responds. Anyone who types /admin/users gets the admin panel.
Hiding UI is not access control. It’s a cosmetic layer over an unprotected system.
Roles First
Add a role column to the users table: ‘user’ | ‘admin’ | ‘support’, defaulting to ‘user’. Roles must be readable only server-side — never expose the role table to the client via RLS.
Create a server-side
requireRole(role)helper that reads the session, looks up the role from the database, and throws if it doesn’t match. Use it in every admin page and every admin API route.Do not rely on client-side checks or JWT claims for authorization.
Reading the role from the database on each request costs a query and removes an entire class of bug. Roles cached in a token stay valid after you revoke them.
Protect Routes and Endpoints Separately
Two layers, both required:
Protect /admin/* with middleware that verifies the admin role server-side before rendering.
Separately, every /api/admin/* route must independently call requireRole(‘admin’). Middleware is not sufficient — routes must be safe if called directly.
Then verify by hand. Log in as a normal user and request an admin API route directly:
curl -i localhost:3000/api/admin/users -H "Cookie: $NORMAL_USER_SESSION"
403 or it isn’t protected.
What the Panel Does
Build /admin with: a users table (email, signup date, plan, status) with search and pagination; a user detail page showing their records; the ability to change a user’s plan and suspend an account.
All queries server-side. Paginate at 50. Search must run in the database, not by filtering fetched rows.
Note that admin queries deliberately don’t filter by user ID — that’s the point of an admin panel. Which is exactly why the role check has to be right: it’s the only thing standing between a normal user and everyone’s data.
Audit Logs
The moment an admin can change something, you need to know who changed what.
Create an
audit_logtable: id, actor_user_id, action, target_type, target_id, metadata jsonb, ip_address, created_at.Log every admin write — plan changes, suspensions, deletions, impersonation. Include before and after values in metadata. The log must be append-only: no updates or deletes, enforced by RLS policy.
Append-only matters. A log an admin can edit is not evidence of anything.
Then surface it:
Add /admin/audit showing the log with filters by actor, action, and date range.
Impersonation, If You Must
Genuinely useful for support. Also the most dangerous thing in your app.
Add support impersonation. Only ‘admin’ role may impersonate, and never another admin or support user. Store the real admin ID alongside the impersonated session. Show a persistent, non-dismissible banner during impersonation with a one-click exit. Log start and end with timestamps. Impersonated sessions expire after 30 minutes. Impersonation must never be able to change passwords, email addresses, or billing.
That final restriction is the one that keeps impersonation from becoming account takeover.
Destructive Actions
Deletions must be soft deletes with a deleted_at column, never hard deletes. Require typing the user’s email to confirm. Log every deletion. Add an admin-only restore.
Hard deletes in an admin panel plus one misclick is unrecoverable data loss.
The Checklist
- Role stored server-side, read from the database per request
-
requireRolecalled in every admin page and every admin API route - Middleware protection plus per-route checks
- Verified: normal user gets 403 calling admin APIs directly
- Verified: logged-out request gets 401
- Role table not exposed to client via RLS
- Audit log on every admin write, append-only
- Soft deletes with typed confirmation
- Impersonation banner, expiry, and logging (if implemented)
- Impersonation can’t change credentials or billing
- Admin actions rate limited
Two Habits Worth Keeping
Test as a normal user, not as yourself. Keep a permanent non-admin test account. Every time you add an admin feature, try to reach it from that account. This catches more than any amount of reading.
Assume the panel will be found. /admin is the first path anyone tries. Obscurity buys you nothing; the role check is the only thing that matters.
Next: Vibe code a SaaS dashboard for the user-facing equivalent, or vibe coding security for the full audit.
Related reading: