Skip to content

Role checks that survive a layout refactor

A check that lives only in a layout disappears the day someone moves the page. Put the boundary where the route is, and check again where the work happens.

Admin panel4 min readships at docs/solutions/admin-panel/role-checks-that-survive-a-refactor.md

Tags: nextjs · authorization · admin · layouts · refactoring

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) wrapping admin/), 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:

IdentityPageRoute handlerServer action
Signed outredirect to sign-in401refused
Signed in, wrong role404 or no-access403refused
Adminrenders200performs

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.