You own authentication for a Next.js App Router codebase that a dozen product teams add routes and Server Actions to. How do you decide what the middleware gate is responsible for versus what each feature must enforce, and how do you keep an unprotected route from shipping?
answer
- allocate by what each layer can know
- invert the matcher: deny by default
- the safe path must be the shortest path
- enforce with lint and route tests
- assume the gate can be skipped entirely
basics
~20 sMake middleware a deny-by-default gate for signed-in-ness only, and route every data access through one session-verifying module. Keep routes safe by construction — a negative matcher, an enforced import boundary, and tests that enumerate routes — not by asking teams to remember.
solid answer
~60 sI split it by what each layer can actually know. Middleware owns the coarse, cross-cutting decision — is anyone signed in, and where do we send them if not — and it is configured deny-by-default: the matcher covers everything except static assets, with public paths as an explicit, reviewed exception list. Feature teams own everything resource-shaped: tenancy, ownership, roles per object. The part that matters at scale is that neither is left to memory. I give teams one module that returns a verified session and one data-access layer that cannot be called without it, mark it `server-only`, and enforce with a dependency rule that route files may not import the database client directly. Then a test enumerates the route manifest and asserts every non-public route redirects or fails for an anonymous request, so a new page ships gated or it does not ship. I also assume the gate can be skipped: the 2025 Next.js middleware bypass advisory made single-layer gating a public lesson, and matcher gaps do it quietly all the time.
go deeper
Know that protection is layered: a central gate for signed-in-ness, plus a check in the code that fetches data. Do not assume a route is safe because a gate exists somewhere upstream.
Explain why a matcher that lists protected paths fails open for new routes, and why the inverted form — match everything except reviewed public paths — is the safer default.
Show how you make the rule stick in a real codebase: one verified-session module, an import boundary that route files cannot cross, and a test that enumerates routes and rejects anonymous 200s.
Own the invariant and its cost: state that removing middleware entirely must not expose data, keep the public-path exception list small and reviewed, and budget what the per-request gate is allowed to spend across all traffic.
## Split by what each layer can know The allocation should not be a taste call. Middleware sees a URL and a credential before the route resolves; feature code sees the actual record it is about to return. So: - **Middleware owns:** is there a credential at all, the redirect to login with the destination preserved, and coarse route-family rules that are genuinely URL-shaped. - **Feature code owns:** identity resolution, revocation, tenancy, ownership, per-object roles, and field-level visibility. Stated that way, a reviewer can settle any given rule by asking whether the decision needs to know which record is involved. ## Configure the gate to fail closed The single highest-leverage choice is the matcher's polarity. A matcher that lists protected prefixes means every new route is public until someone remembers to add it, and the failure is silent — nothing breaks, the page just works for anonymous users. Invert it: match everything except static assets and a short, explicitly reviewed list of public paths. Now a new route is gated by default and the failure mode of forgetting is an over-eager redirect, which someone reports on day one. That inversion turns "remember to protect it" into "justify making it public", and the public list becomes a small artefact a security reviewer can read in a minute. ## Make the safe path the only path At a dozen teams, guidance in a wiki decays. Encode it: - **One session module.** `requireUser()` / `requireRole()` that verify integrity and resolve liveness against the store. Nobody writes their own. - **A data-access layer.** Queries live in functions that call the session module first and scope by the session's tenant. Feature code imports those, not the database client. - **`import 'server-only'`** at the top of those modules, so an accidental import from a client component is a build error rather than a leak. - **An enforced import boundary** — a dependency-graph rule or lint rule that forbids `page.tsx`, `route.ts` and action modules from importing the driver directly. That is what converts a convention into a gate. The goal is that the shortest way to get data is also the authorized way. Any design where the safe path is longer than the unsafe one loses to deadlines. ## Verify continuously, not at review time Two cheap checks catch most regressions. First, enumerate the routes the build produced and assert that an unauthenticated request to each non-public one does not return a 200 — this is the test that fails when someone adds a route and forgets. Second, assert the public exception list in the matcher matches the list the test knows about, so the two cannot drift apart silently. For Server Actions, a review checklist item beats nothing but a lint rule beats the checklist: require that every module exporting an action begins with the session call, or that actions are declared through a wrapper that performs it. ## Assume the gate can be skipped This is the part that makes the layering non-negotiable rather than merely tidy. In March 2025, CVE-2025-29927 showed that a crafted value for an internal header — `x-middleware-subrequest`, used by Next.js to stop middleware from recursing into itself — could cause middleware to be skipped entirely for a request. Applications that used middleware as their only authorization layer were, for the duration, wide open; applications that also checked in their data layer were not. Patched releases were issued across the then-current major lines, and the bug itself is history — the design lesson is not. A single gate that can be bypassed by anything (a framework bug, a matcher regex that does not match what you thought, a rewrite, a misconfigured proxy that strips a header) protects nothing behind it. So the standing rule: middleware is an optimization and a second layer. If removing it entirely would expose data, the architecture is wrong. ## Budget the cost Middleware runs on every matched request, so it is a tax on your whole traffic profile. Keep it to parsing a cookie and verifying a signature; keep network calls out of it. Excluding static assets and prefetch-heavy paths from the matcher is a real performance decision, not just hygiene — and it is also why the exception list needs review, because every exclusion is both a latency saving and a potential hole. ## What you tell the org Write it as an invariant, not a guideline: *no route or action returns private data without having called the session module; middleware never decides who may see what.* Invariants can be tested, reviewed against, and pointed at in an incident. Guidelines get skimmed.
- How would you migrate an existing codebase that already treats middleware as its authorization layer?Add the layer rather than swap it. Introduce the verified-session module and the data-access wrapper, land the route-enumeration test in reporting mode to get the true inventory, then convert the highest-value routes first — anything touching money, personal data or admin. Only once every route passes do you flip the test to blocking and invert the matcher, so you never have a window where the old gate is gone and the new one is incomplete.
- Two teams want different login redirects for their sections. Does that argue for per-team middleware?No — Next.js runs a single middleware entry point anyway, so the answer is one gate with a small routing table mapping path prefixes to sign-in destinations. Keeping it centralised preserves the deny-by-default property; letting each team ship its own gating logic reintroduces exactly the per-team divergence you are trying to eliminate.
- What do you do about a public marketing page that must stay fast and uncached-by-nobody?Put it on the matcher's exception list explicitly and treat that entry as a reviewed decision. The exception list is the artefact that makes deny-by-default auditable: it should be short, each line should have an owner, and adding to it should be a visible change rather than a matcher regex tweak nobody reads.
saying these in an interview costs you the question
- List the protected prefixes in the matcher and add new ones later.
- Middleware is our auth layer; features do not need checks.
- We document the rule in the wiki, teams follow it.
- Server Actions are internal, so the gate covers them.
- If middleware ran, downstream headers can be trusted.