skip to content

In a Next.js app, middleware reads a role claim from the session cookie and admits only role=admin to /admin/*, and the team now treats every page and Server Action under /admin as trusted. Which authorization decisions can that middleware check not make, and where do you enforce them instead?

level: seniorimportance: must knowfreq 58%

answer

  1. it decides before it knows the record
  2. URL is not the authorization input
  3. claims are a snapshot, not the truth
  4. actions are endpoints, not page internals
  5. check where the query runs, scope by session

basics

~20 s

Middleware sees a URL, headers and cookies before the route resolves, so it can gate a route family but cannot decide whether this user may read this record, and its role claim is a snapshot that survives revocation. Enforce per-resource authorization in the code that queries the data.

solid answer

~60 s

That check answers one coarse question — does this caller present a token claiming the admin role for a path under `/admin` — and interviewers want the three things it cannot answer. First, ownership and tenancy: middleware runs before the route resolves and does not know which invoice, org or user record the page is about to load, so "is this row yours" is out of reach. Second, freshness: a role claim inside a token was true when the token was issued, so a demoted or suspended admin keeps passing until it expires or you look the session up. Third, coverage: any surface the matcher misses, and any Server Action or Route Handler invoked directly, gets none of it. So I enforce in the code that touches data — one verified-session helper, and queries scoped by the session's org rather than by anything from the URL — and I re-check at the top of every Server Action, because an action is a callable endpoint, not a child of the page that rendered it. Middleware stays as the cheap redirect and a second layer.

code

typescript · 19 lines
typescript
import 'server-only'
import { redirect } from 'next/navigation'

type Session = { userId: string; orgId: string; role: 'admin' | 'member' }

declare function getVerifiedSession(): Promise<Session | null>
declare function findInvoice(id: string, orgId: string): Promise<unknown>

export async function requireAdmin(): Promise<Session> {
  const session = await getVerifiedSession()
  if (!session || session.role !== 'admin') redirect('/login')
  return session
}

export async function getInvoice(id: string) {
  const session = await requireAdmin()
  // tenancy comes from the verified session, never from the URL
  return findInvoice(id, session.orgId)
}

go deeper

for a junior

Know that a route-level gate is not the same as checking whether this user may see this particular record, and that the second check happens on the server where the data is fetched.

for a middle

Explain why the decision cannot be made in middleware: it runs before the route resolves, and the record's identifier often arrives in a request body rather than the URL. Describe re-checking in the Server Action.

for a senior

Demonstrate the production judgment: scope queries by the verified session rather than by URL input, re-check on every action and handler, and explain the revocation window a claim-based check leaves open and how you bound it.

for a principal

Own the invariant across the codebase — a single verified-session module that data access must pass through, so per-resource authorization is structural rather than a rule each team is asked to remember.

## What middleware has to work with When middleware runs, the framework has an incoming request — method, URL, headers, cookies — and has not yet resolved which route segment will handle it, has not run any layout or page, and has not fetched anything. The decision it can make is therefore a function of the URL string and the credential. That is genuinely useful for one class of rule: *route-family gating*. "Everything under `/admin` requires a token claiming admin" fits perfectly. Everything below that granularity does not fit, and the gap is where real incidents live. ## Decision 1: which record, not which route `/admin/invoices/4712` tells middleware that an invoice id appeared in the path. It does not tell it whether invoice 4712 belongs to the caller's organisation, whether it is soft-deleted, or whether this admin is an admin *of that org* rather than of a different one. Multi-tenant systems fail here constantly: every tenant admin passes an `/admin` gate, and one of them changes the id in the URL. You might be tempted to parse the id in middleware and check it. Resist it — the id is only sometimes in the URL. A Server Action receives its arguments in the request body, a Route Handler may take a list of ids in JSON, and a page may derive the record from a saved filter. The URL is not a reliable channel for the authorization input, so the URL is not the right place for the decision. ## Decision 2: freshness A role claim carried inside a self-contained token is a statement about the past. If you demote an admin at 10:00 and their token expires at 10:30, they hold admin for thirty minutes as far as middleware is concerned, because middleware verifies a signature rather than consulting the system of record. That is the intended trade of stateless tokens, and it is fine — as long as the operation that actually matters re-checks against the store. Short lifetimes narrow the window; they do not close it. ## Decision 3: coverage The gate protects the paths the matcher matched. A Route Handler under a prefix nobody added, a new segment introduced by another team, a rewrite that lands somewhere unexpected — each is a hole, and the hole is silent. Server Actions deserve special mention: an action is not "code inside the page", it is a callable server endpoint that happens to be authored next to the component. Rendering the page it lives on is not a prerequisite to invoking it. A related anti-pattern is having middleware inject something like a user id header for downstream code to trust. That is only as strong as the guarantee middleware ran at all; if any path reaches the route without it, the client's own header value is what arrives. ## Where the enforcement goes Put the decision in the function that touches the data, and make the session — not the request URL — the source of the scope: ```ts export async function getInvoice(id: string) { const session = await requireAdmin() // verify + look up, throws or redirects return findInvoice(id, session.orgId) // scoped by who is asking } ``` Two properties make this hold. The query cannot run without a verified session, so forgetting the check is a compile-or-runtime failure rather than a silent hole. And the tenancy filter comes from the verified session, so a tampered id simply finds nothing instead of finding someone else's row. Every Server Action and Route Handler starts with the same call. `import 'server-only'` at the top of that module keeps it from being pulled into a client bundle by an accidental import. ## What about layouts? A common suggestion is to check auth once in `app/admin/layout.tsx` and let children inherit it. Do not rely on it as the boundary. Layouts are preserved across client-side navigation between their child routes, so the check does not re-run on every navigation, and — more importantly — a Server Action or Route Handler in that subtree never renders the layout at all. A check in a layout is a convenience, not a gate. ## What middleware keeps doing It still earns its place: a signed-out visitor gets one consistent redirect with their destination preserved instead of six different error states, obviously-unauthenticated traffic is turned away before any rendering or data fetching, and an attacker now has to get past two independent layers. State that split explicitly in the interview — middleware for the cheap coarse decision, the data layer for the decision that is allowed to be wrong exactly zero times.

  • Why is a check at the top of app/admin/layout.tsx not sufficient as the gate?
    Because layouts are preserved across client navigation between their children, so the check does not re-run on each one, and because Server Actions and Route Handlers in that subtree execute without rendering the layout at all. Treat it as a convenience for the rendered pages and keep the enforcing check in the function that reads the data.
  • Middleware sets an x-user-id header from the verified token so downstream code can skip re-verifying. What is wrong with that?
    It is trustworthy only if middleware provably ran on every path that reaches the handler. Any matcher gap, rewrite or framework-level skip means the client's own header value arrives instead, and downstream code that trusts it hands an attacker whichever identity they typed. Derive identity from the credential where you use it rather than from a header someone upstream promised to overwrite.
  • How do you narrow the window where a revoked admin still passes a claim-based check?
    Shorten the token lifetime so re-issue forces a store visit, and check a revocation signal on the operations that matter — a session lookup, a version counter on the user that must match the claim, or a deny list for recently revoked sessions. Reads of low-value data can tolerate the staleness; state-changing admin actions should not.

saying these in an interview costs you the question

  • Middleware gated /admin, so pages under it are safe.
  • The role is in the token, so it is current.
  • Server Actions are internal, only my UI calls them.
  • Parse the id from the URL in middleware and check it there.
  • One check in the layout covers everything beneath it.

context