How the admin page gets built
Somewhere in the second week, the app needs a page only you can see: every user, every booking, a button to refund or ban. The request is “add an admin dashboard”, and the generated version is a page at /admin with a table on it and, quite often, no check at all. It is not linked from the navigation, which the agent may describe as “hidden”. The next version adds if (!user.isAdmin) redirect("/") on the page, and the routes the page calls still accept anyone.
Three separate mistakes live in that history, and each has a fix.
Unlinked is not protected
The path /admin is in the JavaScript bundle, in the sitemap if one is generated, in the browser history of every machine you have used, and in the guesses of every scanner, which try /admin, /dashboard, /api/admin/users on every site they find. Anything reachable by URL is reachable by everyone, and the only question is what the server does when the request arrives.
The flag a form can set
is_admin as a column on the users table is the natural design and a hole in two ways. The mass-assignment lesson showed the first: a profile route that spreads the body writes the column. The second is subtler: any bug that lets a user write to their own row (an import feature, a settings sync, a migration with a bad default) becomes a privilege escalation.
Keep the list of administrators somewhere the application code cannot write:
// lib/admin.ts
const ADMIN_HANDLES = new Set((process.env.ADMIN_HANDLES ?? "").split(",").map((s) => s.trim()).filter(Boolean));
export function isAdmin(user: User | null) {
return !!user && ADMIN_HANDLES.has(user.handle);
}An environment variable, set on the host, read at startup. No route can change it, no migration can default it, and the list of people with the power is visible in one place. An empty variable means nobody is an admin, which is the safe failure. This is how Addaly’s own admin console is gated, and it fails closed for the same reason.
Check in the route, every time
Middleware (code that runs before every request matching a path) is a fine first layer and a poor only one. Frameworks have shipped vulnerabilities in which a crafted header caused middleware to be skipped; one in a widely used framework in 2025 let a single header bypass every middleware-based auth check on affected sites. Routes reachable through a second path, a rewrite or a different handler never met the middleware at all.
So each admin route, and each admin action, opens with its own check:
export async function POST(request: Request) {
const user = await getSessionUser(request);
if (!isAdmin(user)) return new Response("Not found", { status: 404 });
...
}Two lines, at the top, before anything else. The same two lines on the page component that renders the dashboard, because a server-rendered page is a route too. Middleware in front of all of it, as the belt beside the braces.
404, not 403
A 403 on /admin announces that the page exists and is worth attacking. A 404 says nothing, and an unauthorised person cannot tell an admin page from a typo. Return the same not-found response the rest of the site returns, and log the attempt on your side with the address and the session, because a stranger requesting /api/admin/users is information.
What admins can do is logged
Every admin action (a refund, a ban, a data export, a change to someone’s plan) writes a row: who, what, which record, when. This is not distrust of yourself. It is the record that answers “why is this account banned” six months later, the evidence when a user disputes an action, and the trail that shows what happened if an admin session is ever stolen. An admin page with no audit log is an admin page whose history is whatever the admin remembers.
The admin session is the valuable one
A stolen ordinary session exposes one user. A stolen admin session exposes all of them. Three cheap measures: a shorter session lifetime for admins, re-authentication before the destructive actions (a fresh sign-in, or a one-time code, before a delete or an export) and a second factor on the admin’s own account at the identity provider. None of these are generated by default; all of them are an afternoon.
The test
Sign out. Request /admin and each /api/admin/... route directly, with the developer tools, as nobody. Then as an ordinary user. Every response should be the site’s normal 404. Then read the top of each admin route and find the two lines. A route without them, however well hidden the page, is open.
Every lesson here is open. Your progress is recorded once you pass the Building with AI exam.
Check your understanding
An app’s admin pages are protected by middleware that checks the session’s is_admin flag before letting requests through to anything under /admin. Which statement about this arrangement is accurate?
Pick the one you would defend. Your answer stays private.
Stuck on something?Ask Beatriz, your AI tutor
