Here is a bug that has shipped in a lot of apps, including well-reviewed ones.
An admin section is protected by a check in its layout. Months later someone adds a page, or moves one, or flattens a route group during a tidy-up. The page still renders. The nav still links to it. Nothing fails. It is now public, and there is no way to see that from the file.
The problem is not the layout check. It is treating a rendering boundary as the only boundary.
What a layout check actually guarantees
// src/app/(admin)/layout.tsx
export default async function AdminLayout({ children }: { children: ReactNode }) {
const admin = await requireRole("admin", "/admin");
return <AdminShell user={toShellUser(admin)}>{children}</AdminShell>;
}
This is genuinely good. Every page inside the group renders as a child of this
layout, so a non-admin never sees the shell. It also costs nothing to reuse
what the check returned: requireRole hands back the whole SessionUser, so
the shell can name the account without a second session read.
What it guarantees is narrow, and worth stating precisely:
- It runs for requests that render a page inside that group.
- It does not re-run when someone navigates between pages on the client: the App Router keeps the layout and fetches only the page. A layout check can be minutes stale.
- It does not run for route handlers, which live outside the group.
- It does not run for server actions invoked from those pages. Those are separate POST requests to a generated endpoint.
- It stops applying the instant a file moves out of the group.
The three failure modes
1. The move. src/app/(admin)/admin/reports/page.tsx becomes
src/app/reports/page.tsx because someone wanted a shorter URL. Protection
gone, no error.
2. The action. An admin page has a "Delete user" button wired to a server action. The action does not check anything, because the page it lives next to is protected. But a server action is an HTTP endpoint whose id is in the client bundle. Anyone signed in can call it.
3. The handler. src/app/api/admin/export/route.ts is not in the group at
all. Route handlers are never covered by a page layout, and this one is a data
export.
The model that survives
Boundary at the route. The layout check stays. It is the thing that stops a non-admin ever seeing the shell.
Check again in the page. Each page calls the same guard with its own path:
export default async function AdminReportsPage() {
await requireRole("admin", "/admin/reports");
// ...
}
Wrap the session read in React cache and the second call is free. It closes
the stale-layout gap, and it means a page that gets moved out of the group is
still protected.
Check again at the work. Every server action and route handler that does something privileged authorises itself, on its first line, before it reads its arguments:
"use server";
export async function deleteUser(userId: string) {
await requireRole("admin");
// ...
}
export async function POST() {
try {
await requireApiRole("admin"); // 401/403, never a redirect
// ...
} catch (error) {
const denied = authErrorResponse(error);
if (denied) return denied;
throw error;
}
}
That is not duplication. The layout answers "may this person see this shell", the page "may they see this data", the action "may they do this thing". They are different questions, and only the second one matters to an attacker with curl.
One implementation of the rule. Both call the same requireRole from
src/lib/auth/session.ts. When the rule changes (a new role, a ban check, an
organisation scope), there is one place to change it. Inline comparisons like
user.role === "admin" scattered through pages are what make a refactor
dangerous.
Add pages and actions through a checklist. A skill or a template that puts the file in the protected group, adds the nav entry and writes the checks makes the safe version the default. Consistency by construction beats consistency by review.
Making a move loud
You cannot make Next.js fail a build because a file left a route group, but you can make the omission visible:
- Grep as a habit:
grep -rL "requireRole\|requireApiRole" src/app/api/admin/lists handlers with no check. An empty result is the state you want. - Keep the route group and the URL segment the same word (
(admin)wrappingadmin/), so a file whose path no longer contains both reads as suspicious. grep -rL "requireRole" "src/app/(admin)"lists pages with no check of their own. The only files it should name are layouts, loading and error boundaries.- Write a smoke test that requests a handful of admin routes with a non-admin session and asserts on the status. It is a dozen lines and it is the only thing on this list that catches the mistake automatically.
The test to run after any routing change
For each admin route, with three identities:
| Identity | Page | Route handler | Server action |
|---|---|---|---|
| Signed out | redirect to sign-in | 401 | refused |
| Signed in, wrong role | 404 or no-access | 403 | refused |
| Admin | renders | 200 | performs |
Nine cells, five minutes. Run them after any change that moves a file between directories, renames a route group, or introduces a new layout: the three edits that quietly change who can reach what.