{"course":{"slug":"building-apps-with-ai","title":"Building Apps with AI","url":"https://addaly.com/learn/building-apps-with-ai","level":"beginner","track":"build"},"lesson":{"slug":"the-admin-page-nobody-protected","title":"The admin page nobody protected","url":"https://addaly.com/learn/building-apps-with-ai/the-admin-page-nobody-protected","position":76,"of":87,"minutes":8,"answer":"An admin route is protected by a check in the route itself against an allowlist the app cannot write to, not by being unlinked, not by a flag a form can set.","keyIdea":"An admin route is protected by a check in the route itself against an allowlist the app cannot write to, not by being unlinked, not by a flag a form can set, and not only by middleware."},"body":{"format":"markdown","text":"## How the admin page gets built\n\nSomewhere 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.\n\nThree separate mistakes live in that history, and each has a fix.\n\n## Unlinked is not protected\n\nThe 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.\n\n## The flag a form can set\n\n`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.\n\nKeep the list of administrators somewhere the application code cannot write:\n\n```typescript\n// lib/admin.ts\nconst ADMIN_HANDLES = new Set((process.env.ADMIN_HANDLES ?? \"\").split(\",\").map((s) => s.trim()).filter(Boolean));\nexport function isAdmin(user: User | null) {\n  return !!user && ADMIN_HANDLES.has(user.handle);\n}\n```\n\nAn 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.\n\n## Check in the route, every time\n\nMiddleware (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.\n\nSo each admin route, and each admin action, opens with its own check:\n\n```typescript\nexport async function POST(request: Request) {\n  const user = await getSessionUser(request);\n  if (!isAdmin(user)) return new Response(\"Not found\", { status: 404 });\n  ...\n}\n```\n\nTwo 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.\n\n## 404, not 403\n\nA 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.\n\n## What admins can do is logged\n\nEvery 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.\n\n## The admin session is the valuable one\n\nA 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.\n\n## The test\n\nSign 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."},"check":{"question":"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?","options":[{"text":"It is sufficient, because middleware runs before every request and there is no way to reach a route without passing through it, so a check there covers every path.","correct":false,"tempting":"Frameworks have shipped bugs that let a crafted request skip middleware, and a route reached by another path is unprotected; the route must also check."},{"text":"It is sufficient as long as the admin pages are not linked from anywhere a normal user can see.","correct":false,"tempting":"Unlinked is not unreachable; the path is in the bundle and in the browser history of anyone who has used it."},{"text":"It is a reasonable first layer, and each admin route and action still needs its own check against an allowlist the app cannot modify.","correct":true},{"text":"It is wrong because admin checks must happen on the client, where the page is rendered.","correct":false,"tempting":"Client-side checks decide what to draw; they decide nothing about what the server will do for a request."}]},"prev":{"slug":"uploads-that-run","title":"Uploads that run","url":"https://addaly.com/learn/building-apps-with-ai/uploads-that-run"},"next":{"slug":"asking-the-model-to-audit-itself","title":"Asking the model to audit itself, and what it cannot see","url":"https://addaly.com/learn/building-apps-with-ai/asking-the-model-to-audit-itself"},"authorship":"Founder and series editor: Dr Mustafa Mun. Written by AI models to a written standard. Exam keys checked blind by a second model: 3,665 of 3,666 agreed. Academic review invited.","review":{"status":"Draft, open for academic review"},"cite":"Addaly (2026) 'The admin page nobody protected', in Building Apps with AI. Edited by M. Mun. Addaly. Available at: https://addaly.com/learn/building-apps-with-ai/the-admin-page-nobody-protected (Accessed: 13 October 2026).","licence":{"name":"CC BY-NC-SA 4.0","url":"https://creativecommons.org/licenses/by-nc-sa/4.0/"},"reader":{"schema":1,"title":"The admin page nobody protected","courseTitle":"Building Apps with AI","module":{"index":8,"slug":"the-holes-ai-code-has-by-default","title":"The security holes AI code has by default"},"answer":null,"keyIdea":"An admin route is protected by a check in the route itself against an allowlist the app cannot write to, not by being unlinked, not by a flag a form can set, and not only by middleware.","hero":{"kind":"plate","image":null,"alt":null,"caption":null},"blocks":[{"t":"h2","id":"how-the-admin-page-gets-built","runs":[{"text":"How the admin page gets built"}]},{"t":"p","runs":[{"text":"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 "},{"text":"/admin","code":true},{"text":" 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 "},{"text":"if (!user.isAdmin) redirect(\"/\")","code":true},{"text":" on the page, and the routes the page calls still accept anyone."}]},{"t":"p","runs":[{"text":"Three separate mistakes live in that history, and each has a fix."}]},{"t":"h2","id":"unlinked-is-not-protected","runs":[{"text":"Unlinked is not protected"}]},{"t":"p","runs":[{"text":"The path "},{"text":"/admin","code":true},{"text":" 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 "},{"text":"/admin","code":true},{"text":", "},{"text":"/dashboard","code":true},{"text":", "},{"text":"/api/admin/users","code":true},{"text":" 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."}]},{"t":"h2","id":"the-flag-a-form-can-set","runs":[{"text":"The flag a form can set"}]},{"t":"p","runs":[{"text":"is_admin","code":true},{"text":" 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."}]},{"t":"p","runs":[{"text":"Keep the list of administrators somewhere the application code cannot write:"}]},{"t":"code","lang":"typescript","label":"TypeScript","text":"// lib/admin.ts\nconst ADMIN_HANDLES = new Set((process.env.ADMIN_HANDLES ?? \"\").split(\",\").map((s) => s.trim()).filter(Boolean));\nexport function isAdmin(user: User | null) {\n  return !!user && ADMIN_HANDLES.has(user.handle);\n}"},{"t":"p","runs":[{"text":"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."}]},{"t":"h2","id":"check-in-the-route-every-time","runs":[{"text":"Check in the route, every time"}]},{"t":"p","runs":[{"text":"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."}]},{"t":"p","runs":[{"text":"So each admin route, and each admin action, opens with its own check:"}]},{"t":"code","lang":"typescript","label":"TypeScript","text":"export async function POST(request: Request) {\n  const user = await getSessionUser(request);\n  if (!isAdmin(user)) return new Response(\"Not found\", { status: 404 });\n  ...\n}"},{"t":"p","runs":[{"text":"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."}]},{"t":"h2","id":"404-not-403","runs":[{"text":"404, not 403"}]},{"t":"p","runs":[{"text":"A 403 on "},{"text":"/admin","code":true},{"text":" 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 "},{"text":"/api/admin/users","code":true},{"text":" is information."}]},{"t":"h2","id":"what-admins-can-do-is-logged","runs":[{"text":"What admins can do is logged"}]},{"t":"p","runs":[{"text":"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."}]},{"t":"h2","id":"the-admin-session-is-the-valuable-one","runs":[{"text":"The admin session is the valuable one"}]},{"t":"p","runs":[{"text":"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."}]},{"t":"h2","id":"the-test","runs":[{"text":"The test"}]},{"t":"p","runs":[{"text":"Sign out. Request "},{"text":"/admin","code":true},{"text":" and each "},{"text":"/api/admin/...","code":true},{"text":" 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."}]}],"check":{"question":[{"text":"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?"}],"options":[{"text":[{"text":"It is sufficient, because middleware runs before every request and there is no way to reach a route without passing through it, so a check there covers every path."}],"correct":false,"tempting":[{"text":"Frameworks have shipped bugs that let a crafted request skip middleware, and a route reached by another path is unprotected; the route must also check."}]},{"text":[{"text":"It is sufficient as long as the admin pages are not linked from anywhere a normal user can see."}],"correct":false,"tempting":[{"text":"Unlinked is not unreachable; the path is in the bundle and in the browser history of anyone who has used it."}]},{"text":[{"text":"It is a reasonable first layer, and each admin route and action still needs its own check against an allowlist the app cannot modify."}],"correct":true,"tempting":null},{"text":[{"text":"It is wrong because admin checks must happen on the client, where the page is rendered."}],"correct":false,"tempting":[{"text":"Client-side checks decide what to draw; they decide nothing about what the server will do for a request."}]}],"order":[3,2,1,0],"why":null},"prev":{"slug":"uploads-that-run","title":"Uploads that run","minutes":8},"next":{"slug":"asking-the-model-to-audit-itself","title":"Asking the model to audit itself, and what it cannot see","minutes":8}}}