Admin routes protected only by hiding the button
What you'd see: Your admin area is protected by something like {user.isAdmin && <AdminPanel />}.
§ 01 — What's actually happening
That line decides what to <em>draw</em>, not what to <em>allow</em>. The user object came from the browser, the routing happens in the browser, and the API endpoints behind it are usually left open because "only admins can get there". Typing the URL directly, or calling the endpoint with curl, skips the entire check.
Why an AI tool writes it this way
"Add an admin dashboard visible only to admins" reads like a UI instruction, and an AI implements it as one. The conditional render satisfies the request completely — clicking around as a normal user, the admin area really is invisible.
What it costs you
Anyone who guesses <code>/admin</code> — and it is always <code>/admin</code> — reaches every user record, every export, every delete button. No skill required. This is the finding that most often turns into a real incident, because it needs no tooling at all.
§ 02 — Before & after
The admin panel that hides rather than locks
Illustrative code, written for this page — never a client's project.
AI-generated
- The check runs in the browser, where the user controls everything
- API routes behind the panel have no check of their own
- The role comes from client state that can be edited
Human-reviewed
- Every admin endpoint verifies the role server-side, on every request
- Role read from the database, not from the token or the request
- The UI check stays — as presentation, not as security
§ 03 — Check your own
How to tell in two minutes
You don't need us to run these. If any of them come back the wrong way, you have this problem.
- Sign in as an ordinary user and type
/admininto the address bar. If the page renders, you have a finding. - Then try the API directly:
curl -H "Cookie: <your normal session>" https://yourapp.com/api/admin/users. Getting data back is the real failure — the page is only the symptom. - Search for every route under an admin path and confirm each one calls the role check. One missed route is enough.
- Check that the role is read from the database on each request. A role baked into a long-lived JWT stays valid after you demote someone.
Found it in your project? Fixing this one properly usually takes an engineer under an hour. Finding the other nine takes longer — which is what the free health check is for.
Get my free health check →§ 04 — Related
Other things we find
Records fetched by ID with no ownership check (IDOR)
Endpoints look like findUnique({ where: { id } }) with the ID taken straight from the URL.
CORS set to allow everything, with credentials on
You have app.use(cors()) or an origin reflected from the request header, plus cookie-based sessions.
Stripe webhook accepts unverified events
Your webhook route reads the request body and acts on it directly.
Read the fix →