Missing auth on admin routes
What it is
Admin routes are the endpoints that do powerful things: list every user, change roles, issue refunds, delete accounts. Missing auth on an admin route means the endpoint runs for anyone who calls it, signed in or not. A close cousin is an admin route that checks you're logged in, but not that you're an admin.
Think of a building where the server room is at the end of an unlisted hallway. Nobody put a sign on it, so nobody thought to lock it. Then someone walks down the hallway.
Why it matters
"Nobody knows the URL" is not protection. Admin paths follow predictable names (/admin, /api/admin/users, /internal), appear in JavaScript bundles, and show up in API docs, mobile apps and browser history. Automated scanners request common admin paths on every site they find.
When one of those routes is open, the impact is whatever the route can do. That might be a full user export, a role change that makes the caller an admin, deleted data, or refunds and credits issued to any account.
Broken function level authorization, meaning endpoints that don't check whether the caller may use that function, is a category of its own in the OWASP API Security Top 10.
How it happens
Usually auth is applied route by route, and one route gets missed. Admin routes are especially easy to miss because they're built last, tested by the one person who's always logged in as admin, and never linked from the public UI.
// app/api/admin/users/[id]/route.ts (Next.js route handler)export async function DELETE(_req: Request, { params }: { params: Promise<{ id: string }> }) { const { id } = await params; await db.user.delete({ where: { id } }); return Response.json({ ok: true });}The second form, login without a role check, looks like this: the handler confirms there's a session, then does admin work for whoever it is.
How it's abused
At a conceptual level:
- Collect candidate paths: common admin names, routes referenced in the frontend bundle, endpoints from API docs or mobile apps.
- Call each one without signing in, then again as an ordinary user.
- Note anything that answers with data or performs an action instead of 401 or 403.
- Use the open endpoint directly, for example to list users, change a role or delete records.
How to fix it
Protect admin routes in two layers: require a session, then require the admin role, on the server, for every handler. Put both checks in one shared place so a new route can't forget them.
export async function DELETE(_req: Request, { params }: { params: Promise<{ id: string }> }) { const session = await auth(); if (!session) return new Response(null, { status: 401 }); if (session.user.role !== "admin") return new Response(null, { status: 403 }); const { id } = await params; await db.user.delete({ where: { id } }); return Response.json({ ok: true });}Why this works: the check runs on the server for every request, before the handler does anything. Attaching it to the router or route group, rather than to each handler, means a new admin route inherits it automatically.
More good practice:
- Never rely on hiding. Hiding the admin link in the UI is fine for tidiness, but the server must check every time.
- Check the role, not just the session. Logged in is not the same as allowed.
- Read the role from the server. Take it from the session or the database, never from a request field or a client-side flag.
- Protect every method. GET, POST, PUT, PATCH and DELETE on the same path each need the check.
- Log admin actions so you can see who did what.
Common mistakes
- Checking only in middleware that doesn't match every path. A matcher like
/admin/:pathmay miss/api/admin/...or a new route outside the folder. - Checking the role from a cookie or header the client controls. An
isAdmin=truecookie is a suggestion, not a fact. Read the role from your database or a signed session. - Protecting the page but not the API. The admin page redirects non-admins, but its API endpoints answer anyone.
- Leaving debug or seed endpoints in production, such as
/api/seed,/api/resetor/debug.
Testing your fix
List every admin endpoint (your framework's route list helps). Call each one three ways: signed out, signed in as a normal user, and signed in as an admin. Only the last should succeed; the others should return 401 and 403. Make this an automated test so a new admin route without the check fails the build.
How SetRepo detects it
SetRepo builds a route inventory of your server: every endpoint, its method and the auth guards in front of it. Route extraction covers JavaScript and TypeScript, Python, Go, Java and Kotlin, PHP, Ruby, Rust and C#. This runs on every Quick and Deep scan.
- Admin endpoints with no authentication: admin routes with no auth guard at all.
- Admin endpoints without a role check: admin routes that require a login but not an admin role.
- Unauthenticated changes: non-admin routes that change data with no authentication.
- Pages protected only in the browser: checks done in client code with nothing behind them on the server.
Each finding names the route, the file and the line, with a fix prompt. When the check isn't certain, the finding is labeled "verify". Routes or frameworks the inventory couldn't analyse are listed in the report rather than skipped silently. Deep scans add an AI review of access control that double-checks these findings against the surrounding code.
Checklist
- Every admin route requires a session and the admin role, on the server.
- The checks live in shared middleware, a dependency or a route group.
- Every HTTP method on an admin path is protected.
- Roles are read from the server-side session or database.
- Routes that change data require authentication.
- No security decision is made only in frontend code.
- Admin actions are logged.
FAQ
Our admin panel isn't linked anywhere. Isn't that enough?
No. Admin paths are predictable, appear in bundles and docs, and get requested by automated scanners. Every route must check the caller itself.
We check the role in the frontend before showing admin pages. Is that enough?
No. Anyone can call your API without your frontend. The server must make the decision.
401 or 403?
Return 401 when there's no valid session and 403 when the user is signed in but not allowed.
Is a secret admin URL or an IP allowlist enough?
They help as extra layers, but they're not authorization. Secret URLs leak, and IP allowlists break when people work remotely. Keep the session and role checks on every route either way.