Exposed admin routes in Next.js: find them and lock them down
Exposed admin routes are admin pages or API endpoints that respond to anyone who knows the URL. A hidden link or a client-side redirect doesn't stop a direct request. Run a role check on the server in every admin page and every /api/admin handler, and return 401 (not logged in) or 403 (logged in but not allowed) before doing any work.
QUICK CHECKLIST
- Check the admin role on the server in every handler
- Return 401 when logged out and 403 for normal users
- Never read the role from localStorage or user_metadata
- Delete debug, seed and test routes before launch
- Remove default admin credentials and seeded admins
- Call each admin URL logged out and as a normal user
What exposed admin routes are and why they matter
An admin route is any page, API handler or server action that does something only you should be able to do. Think listing users, refunding orders, deleting content or flipping a feature flag. It's exposed when the server answers anyone who asks.
OWASP, the nonprofit that publishes the Top 10 list of web security risks, calls this broken access control (users acting outside their permissions) and ranks it #1 in its 2025 Top 10. Its list of common failures includes forced browsing: guessing URLs until a privileged page answers, either as a logged-out visitor or as a standard user.
AI tools make this easy to ship by accident. Ask for an admin dashboard and you often get a page that hides itself in the browser and an API that trusts whoever calls it. Clicking through the app looks locked because nobody calls the API directly, which is how an admin route with no auth ships in a vibe coded app.
How someone gets into an admin area you thought was hidden
Attackers don't need your link. They try a short list of paths such as /admin, /dashboard, /api/admin/users, /debug and /seed, and they read your JavaScript bundle (the JavaScript your site sends to every browser), which names every route the client knows about. Hiding a nav link removes a button, and the URL still answers.
A client-side redirect has the same hole. By the time the browser's JavaScript sends a non-admin home, the server has already responded, and any API call behind the page still works. OWASP's own example is an app that keeps all access control in the front end: the attacker skips the browser and runs one curl command (a terminal tool that sends a web request directly to your server).
Where the role comes from matters just as much:
localStorage: browser storage that the user can edit in DevTools (the F12 panel in any browser).- A query parameter like
?isAdmin=true: the Next.js data security guide shows this as a vulnerable pattern because it trusts client data. - Supabase
user_metadata: users can change it themselves. See Supabase user_metadata for the details. - A session you read without verifying it. On the server, use
getUser(). Supabase getSession vs getUser explains why.
Leftover developer routes are the other half. /api/seed, /api/debug, a test login or a make-me-admin helper often skip auth because they were meant for your laptop. Add default credentials like admin / admin from a README, and a stranger can walk in without exploiting anything.
Admin checks in the UI versus on the server
The left side below hides the page in the browser and leaves the API open. The right side has one helper that decides who is an admin, and the handler calls it first.
"use client";
// app/admin/page.tsx
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
export default function AdminPage() {
const router = useRouter();
const [users, setUsers] = useState<unknown[]>([]);
useEffect(() => {
if (localStorage.getItem("role") !== "admin") router.push("/");
fetch("/api/admin/users").then((r) => r.json()).then(setUsers);
}, [router]);
return <pre>{JSON.stringify(users, null, 2)}</pre>;
}
// app/api/admin/users/route.ts (no check at all)
import { supabaseAdmin } from "@/lib/supabase/admin";
export async function GET() {
const { data } = await supabaseAdmin.from("profiles").select("*");
return Response.json(data);
}// lib/require-admin.ts
import { createClient } from "@/lib/supabase/server";
export async function requireAdmin() {
const supabase = await createClient();
const { data: { user } } = await supabase.auth.getUser();
if (!user) return { ok: false as const, status: 401 };
if (user.app_metadata?.role !== "admin") return { ok: false as const, status: 403 };
return { ok: true as const, user };
}
// app/api/admin/users/route.ts
import { requireAdmin } from "@/lib/require-admin";
import { supabaseAdmin } from "@/lib/supabase/admin";
export async function GET() {
const check = await requireAdmin();
if (!check.ok) return new Response(null, { status: check.status });
const { data } = await supabaseAdmin.from("profiles").select("id, email");
return Response.json(data);
}The helper uses the server-side Supabase client from the standard Next.js setup, so adapt the import to your project. The role comes from app_metadata, which users can't edit. You set it from your server with the service role key (a Supabase secret that bypasses all access rules and must never reach the browser), for example with supabase.auth.admin.updateUserById.
OWASP puts the rule this way: "Access control is only effective when implemented in trusted server-side code or serverless APIs, where the attacker cannot modify the access control check or metadata."
Middleware (code that runs before a request reaches your page) can redirect early, but keep the check inside the handler too. Next.js middleware authentication covers why one layer shouldn't carry the whole load. A role check also says nothing about whether a record belongs to the caller. That gap is the IDOR problem (changing an ID in a request to reach another user's data).
How to fix exposed admin routes, step by step
Work through these in order. The helper from the example above is the core of it.
List every admin entry point
Search for
app/admin,app/api/admin, and anything named debug, seed, test, dev, setup, migrate or make-admin. Include server actions that change roles, plans or other users' data. Write the list down.Write one requireAdmin helper
It reads the session on the server with
getUser()and checksapp_metadata.roleor an admin flag in a table only your server writes. It never reads localStorage, query strings oruser_metadata. If you use an auth library, use its role features. The Next.js authentication guide recommends libraries over hand-built auth.Call it first in every handler
Put it at the top of every admin page, route handler and server action, before any database call, and always act on its result. It returns
{ ok: false }instead of throwing, so a bareawait requireAdmin()protects nothing.In route handlers, return 401 when nobody is logged in and 403 for a normal user. In pages, call
notFound()orredirect('/login'). In server actions,throw new Error('Unauthorized')when!check.ok.A page-level check doesn't cover the server actions defined inside it, as the Next.js docs point out. See Next.js server actions security.
Delete or lock the leftovers
Remove debug, seed and test routes from production. Delete seeded admin accounts and change any default credentials. If you need a seed step, run it from the command line instead of a URL.
Deny by default
New routes under
/adminor/api/adminshould fail until someone adds a check. OWASP and its Authorization Cheat Sheet both recommend deny by default, meaning every request is refused unless a rule explicitly allows it. Hiding the admin link is optional polish.Add a test
Write a test that calls each admin route logged out and as a normal user and expects 401 or 403. OWASP recommends including access control in unit and integration tests.
Here is the admin page itself as a Server Component (a React component that renders only on the server). It runs the check on the server, so a non-admin never receives the admin UI:
// app/admin/page.tsx
import { notFound } from "next/navigation";
import { requireAdmin } from "@/lib/require-admin";
export default async function AdminPage() {
const check = await requireAdmin();
if (!check.ok) notFound();
return <h1>Admin</h1>;
}Test it yourself
Use two sessions: logged out (a private window works) and a normal user account you create for testing. Admin routes should refuse both.
First, call an admin API with no cookies:
curl -i https://your-app.example/api/admin/users
A bad result is a 200 with JSON in the body. A good result is 401, 403 or 404, or a redirect to login with no data.
Next, sign in as the normal user, open DevTools, go to the Network tab, right-click any request and choose Copy as cURL. Change the URL to your admin endpoint and run it. A 403 is good. A 200 means any logged-in user is an admin.
Then sweep common paths. This is forced browsing, done on yourself:
for p in admin dashboard api/admin api/admin/users debug api/debug seed api/seed test api/test; do
echo "$p: $(curl -s -o /dev/null -w '%{http_code}' https://your-app.example/$p)"
doneAnything that returns 200 deserves a look. A client-rendered /admin page returns 200 for its HTML shell, so test the API calls behind it as well.
To check the page itself, turn off JavaScript in your browser's DevTools (press F12) and reload /admin while logged out. If admin content still appears, the server isn't checking. An empty page doesn't prove you're safe: client-fetched data won't load without JavaScript, so the API checks above are the ones that count.
Finally, find where your code reads the role. Run this from the project root:
grep -rnE "localStorage.*(role|admin)|user_metadata.*(role|admin)|isAdmin" --include='*.ts' --include='*.tsx' --include='*.js' --include='*.jsx' --exclude-dir=node_modules --exclude-dir=.next .
Every hit that decides access is a bug. Move it to the server.
Fix prompt you can paste
Paste this into Cursor or Claude Code at the repo root. Read the diff before you accept it.
Audit this Next.js App Router repo for exposed admin routes. Adapt the auth code if I don't use Supabase.
1. List every page under app/**/admin*, every route handler under app/api/**/admin*, and every route or server action named debug, seed, test, dev, setup, migrate or make-admin.
2. For each one, say whether it verifies the caller on the server before doing any work. Report file and line.
3. Search for isAdmin, is_admin, role and app_metadata, and find where the admin role is read. Flag anything that reads it from localStorage, client-set cookies, query params, request bodies or Supabase user_metadata. Also flag any handler that uses a service role client (for example supabaseAdmin) without a verified admin check above it. Report file and line for each.
4. Create lib/require-admin.ts that gets the user with supabase.auth.getUser() and checks app_metadata.role === "admin". It returns { ok, status }.
5. Call it first in every admin page, route handler and server action, and act on the result. Route handlers return 401 (logged out) or 403 (non-admin). Pages call notFound() or redirect('/login'). Server actions throw before doing any work. Never call it without checking .ok. Remove client-side redirects that were the only protection.
6. Delete debug, seed and test routes, or make them fail in production. List any seeded admin accounts or default credentials you find.
7. Add tests that call each admin route logged out and as a normal user and expect 401 or 403.
Before editing anything, print the findings from steps 1 to 3 as a table of file, line and problem. After the edits, list each file you changed with one sentence on why.Which admin checks stop a direct request
A quick way to judge any admin check is to imagine someone skipping your frontend and calling the server directly. If the check does not run in that case, it protects nothing.
| Stops a direct request? | Why | |
|---|---|---|
| Hidden nav link | No | The URL still answers |
| Client-side redirect | No | The API call still works |
| Role in localStorage | No | Users can edit it |
| Role in user_metadata | No | Users can update it |
| Server check in every handler | Yes | Runs where users can't edit it |
A curl request skips the browser, so only the server-side check still applies.
The vibe coding security checklist puts this check next to the other pre-launch basics. If you'd rather not test by hand, you can point Leakcade at your app.
Further reading
- OWASP Top 10:2025, A01 Broken Access Control
- OWASP Authorization Cheat Sheet
- Next.js docs: How to think about data security
- Next.js docs: How to implement authentication
FAQ
Is hiding the admin link enough to protect /admin?
No. A hidden link only removes a button. Anyone who types the URL or calls /api/admin directly gets whatever the server returns, so every admin page and handler must check the role on the server and return 401 (logged out) or 403 (not an admin).
Is a client-side redirect a secure way to hide an admin page?
No. The redirect runs in the visitor's browser, which they control. The server has usually already answered, and the API behind the page doesn't care about the redirect.
What is forced browsing?
Forced browsing means requesting URLs directly, often by guessing them, to reach pages you weren't linked to. OWASP lists it under broken access control, both for logged-out visitors reaching authenticated pages and for standard users reaching privileged ones.
Where should I store the admin role in a Next.js app?
Keep it where only your server can write it, such as Supabase app_metadata or a roles table, and read it on the server for every request. Don't trust localStorage, query params or user_metadata.
Can I protect /admin with middleware alone?
Middleware is useful for an early redirect, but don't make it the only check. Verify the role again inside each page, route handler and server action, because an attacker only needs one unchecked entry point.
Want a second pair of eyes?
We review your whole repo and write a fix prompt for every finding. $19, report within an hour.
Check my repoLeakcade is an automated check. It can miss vulnerabilities and is not a substitute for a professional security audit or pentest. Provided as is. You stay responsible for your code. Where the law allows, our liability is limited to what you paid ($19). Terms