# 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.

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

```tsx
// 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:

```tsx
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:

```ts
"use server";
export async function deleteUser(userId: string) {
  await requireRole("admin");
  // ...
}
```

```ts
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:

| 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.

---

Agentic Boilerplate: A Next.js repo your agent already knows. Free during launch, then $99 once.

- Site map for agents: https://agenticboilerplate.com/llms.txt
- Public API: https://agenticboilerplate.com/openapi.json
- Contact: agenticstudio@gmail.com
