Correctness & Security

Lesson 1 of 5

Authentication & Sessions

"Due to Partial Rendering, be cautious when doing checks in Layouts as these don't re-render on navigation, meaning the user session won't be checked on every route change." — Next.js Documentation, "Authentication"

A layout is the obvious place to put an authorisation check. It wraps every page in a section, it runs before them, and putting await requireAdmin() at the top of app/admin/layout.tsx reads like exactly the right architecture — one gate, covering everything behind it. It is the pattern every server-rendered framework has encouraged for twenty years.

In the App Router it does not work, and the reason is a property we established in Part I as a performance feature: layouts are cached and reused across navigations rather than re-rendered. The check runs when the layout first renders. It does not run again when the user navigates from /admin/users to /admin/billing, because the layout did not run again. A session that expired, a role that was revoked, an account that was suspended — none of that is noticed until something forces the layout to render afresh.

This is the chapter where the rendering model and the security model meet, and they do not automatically agree. Nearly everything here follows from one principle: put the check where the data is, not where the route is. A gate at the entrance protects the entrance. A check inside the function that reads the data protects the data, every time, from every caller, including the caller somebody adds next year.

The big question

If a component that guards a route might not run on every request, where is the only place an authorisation check can be relied on?

Two kinds of check, and what each may decide

Before mechanisms, a distinction that determines what a check is allowed to conclude.

TypeScript — Try it Yourself
Loading editor…
Type check + Console

The word "optimistic" is doing precise work here. It means assume this is probably right and proceed, which is fine for hiding a menu item and not fine for returning a payroll record.

Everyday analogy

Think about a staff pass and the lock on a medicine cabinet. The pass at the front door is an optimistic check: the guard glances at the photo, sees the right lanyard colour, and waves you through. It is fast, it keeps out people who obviously do not work here, and it is checked against a plastic card rather than against today's employment records — so somebody dismissed on Friday still gets through on Monday morning. The cabinet is different. It reads your card against the live system, every time you open it, because what is inside matters enough to justify the delay. Two things follow from the analogy that also hold in code. First, nobody thinks the front door makes the cabinet unnecessary. Second — and this is the layout trap — the door is checked when you enter the building, not each time you walk between rooms. If the only check is at the door, then everything that happens after you are inside happens on the strength of a glance somebody took an hour ago.

The layout trap

Now the epigraph, demonstrated. This is the single most important thing in the chapter, because the broken version looks more correct than the working one.

TypeScript — Try it Yourself
Loading editor…
Type check + Console

The asymmetry in that output is the whole argument. A check in the layout runs on one navigation in four. A check inside the function that reads the data runs on all four, because every one of those pages had to read data to render.

A little history

Route-level authorisation has been the default in web frameworks since long before React existed — Rails had before_action, Django had decorators and middleware, Spring had URL-pattern security, and every one of them encouraged you to declare "everything under /admin requires an admin" in one place. That was genuinely good design for the architecture it belonged to, because in a request-per-page world the filter chain provably ran on every request. There was no cached layer between the URL and the handler, so a gate at the route was a gate on every access. What has changed is not that route-level checks became a bad idea; it is that the guarantee they relied on quietly stopped holding. Client-side routing broke it first — a single-page application navigating without a request obviously cannot re-run a server-side filter — and server components with cached layouts broke it again in a subtler way, because the code looks server-side and the checks look like they run per request. I find this one of the clearest cases in modern web development of a well-established pattern outliving its own preconditions, and it is worth being alert to: the danger is not the unfamiliar API, it is the familiar one whose foundations moved.

The Data Access Layer

So if checks belong with data, the answer is to make that structural rather than a habit. The documented pattern is a data access layer: one module that owns session verification, and data functions that go through it.

TypeScript — Try it Yourself
Loading editor…
Type check + Console

import 'server-only' is the detail people skip and it is doing real work. It turns an accidental import of your session module from a client component into a build failure, rather than into a bundle containing your decryption logic.

The engineer's notebook

The first thing I look for in an application's auth is whether there is a single function that everything goes through, or a habit of checking in the right places. If it is a habit, I stop looking at the checks and start looking for the data function somebody wrote in a hurry, because that is where the missing one will be. A habit scales exactly as far as the attention of the least-rushed person on the team. The second thing I check is every layout.tsx for anything that looks like a gate — redirect, requireX, a role comparison — and I treat each one as a finding, not because layouts must be free of auth logic but because a gate there provably does not run on sibling navigations and almost nobody who wrote one knew that. The third is smaller and catches a specific class of leak: I grep for imports of the session module from anything with 'use client' at the top. import 'server-only' makes that a build error, which is why the absence of that line is itself worth flagging — it is the difference between a mistake that fails the build and a mistake that ships your token-decryption code to the browser.

Return the minimum: Data Transfer Objects

A check that says yes is only half the problem. The other half is what you hand back, because a server component that fetches a whole row and passes it to a client component has published every column in it.

TypeScript — Try it Yourself
Loading editor…
Type check + Console

The per-field functions look like ceremony until you have to answer "who can see a phone number?" in a code review. Having canSeePhoneNumber as a named function makes that answerable by reading one line, and it makes changing the answer a one-line change in a place tests can reach.

In the real world

A company put a permissions check in the layout of their admin section, reviewed it, and were satisfied — it was one clear gate at the boundary, exactly the architecture everyone had been taught. Months later an offboarding process was tested properly for the first time: revoke a departing employee's admin role and confirm they lose access. They did lose access, on the next full page load. What the test had not covered was a user already inside the section, and when someone tried it, the revoked account clicked through four admin pages without a single check running, because the cached layout never re-rendered. The window was not a few seconds — it lasted as long as the person kept navigating client-side. The fix was to move verification into the data access layer, which took an afternoon and made the layout check redundant rather than wrong. What I found most instructive was the review history: three engineers had read that layout and approved it, and all three were right that the code was correct. It was correct. It simply ran once, and nothing about it said so — the fact that made it insufficient lived in a rendering document, not a security one.

Where checks actually belong

Pulling it together: there are five places a check can live, and each has a different guarantee.

TypeScript — Try it Yourself
Loading editor…
Type check + Console

The paywall case is worth noting because it is the one situation where the proxy is not merely an optimisation. A fully static route shared between users has no per-request render in which to verify anything, so the check has to happen before the route is served.

Common Mistakes

  1. Putting the authorisation gate in a layout. Layouts are cached and do not re-render on navigation, so the check runs on entry and not on sibling navigations.

  2. Concluding that layouts must contain no auth logic. Fetching the user for a nav avatar there is fine; the check belongs in the data access layer.

  3. Letting an optimistic check decide something consequential. A cookie says what was true when it was issued, not what is true now.

  4. Relying on a habit of checking rather than a structure that checks. A habit holds until the next rushed data function.

  5. Omitting import 'server-only' from the session module. That line turns an accidental client import into a build error instead of a shipped bundle.

  6. Skipping cache() on the verification function. Without it, every call site is another session query in the same render.

  7. Passing a whole database row to a client component. Every column in it is now published, including the ones nobody read.

  8. Assuming a Server Action inherits the page's authorisation. It is an independently reachable endpoint and must verify for itself.

  9. Assuming a Route Handler is protected because only your own UI calls it. It is public, and today's callers are not a security property.

  10. Forgetting static routes entirely. A shared prerendered route has no per-request render, so a paywall on it has to be enforced in the proxy.

Interview Corner

Interview Corner — the same idea at three levels. Answer yours; recognise the one above it.
Junior
Where would you check that a user is logged in before showing a dashboard?
Model answer — In the page component, before fetching the dashboard's data — verify the session, and redirect to login if it is not valid. The instinct to put it in the layout is the one to resist, because layouts are cached and do not re-render when the user navigates between pages in the same section, so the check would not run on those navigations. Better still is to have the data-fetching function verify the session itself, so the check happens wherever that data is requested.
Mid
What is a Data Access Layer and why is it recommended here?
Model answer — It is a module that owns session verification and the data functions that depend on it, so that verification is the only way to obtain a user id. That converts "remember to check" into a structural guarantee: a new data function cannot skip the check, because it needs the session to know whose data to fetch. Two details matter in the implementation — import 'server-only', so an accidental import from a client component is a build error rather than a shipped bundle, and wrapping the verification in React's cache so several call sites in one render cost one session query. It also gives you one place a reviewer can read to see what the rules are, which a scattering of inline checks does not.
Senior
A team put their admin check in the admin layout and it passed review. What went wrong?
Model answer — Nothing about the code — it was correct and it ran once. Layouts are cached and reused across client-side navigations, so the check happens on entry to the section and not when the user moves between sibling pages. A role revoked while someone is inside the section is not noticed for as long as they keep navigating client-side. The reason it passes review is that the insufficiency is not visible in the file: it lives in a rendering property documented as a performance feature. The fix is to verify in the data access layer so the check travels with the data, which also makes the layout check redundant rather than wrong — and you can still fetch the user in the layout for the nav avatar. More generally I would treat any gate whose correctness depends on a caching assumption as needing a test that exercises the cached path, because the obvious test — log in, revoke, reload — passes.
Company lens — how the same topic gets asked
micro1Practical: protect a dashboard route and explain where the check goes and why.
GoogleMechanism: which rendering properties invalidate route-level authorisation, and what replaces it?
MetaData shape: design the projection for a profile where different viewers see different fields.
AmazonThreat model: enumerate every independently reachable entry point in a Next.js app and what guards each.
Hands-on

Two functions covering the chapter's two halves. The first decides whether an authorisation check actually executes for a given navigation, given where the check lives — the layout case is the one that matters, and getting it right means modelling the fact that a cached layout does not re-render for a sibling navigation. The second projects a database row into what a specific viewer may see, withholding conditional fields and omitting the ones nobody may ever see.

TypeScript — Try it Yourself
Loading editor…
Type check + Console

Summary

  • Layouts are cached and do not re-render on navigation, so an authorisation gate in a layout runs on entry to a section and not on sibling navigations.

  • Put the check where the data is, not where the route is. A check inside the data function runs for every caller, including future ones.

  • Optimistic checks read the cookie and may decide UI visibility and redirects; they may not decide whether to return sensitive data or permit a mutation.

  • Secure checks read the source of truth, because a cookie describes what was true when it was issued.

  • A Data Access Layer makes verification structural. If the session is the only way to get a user id, no data function can skip it.

  • import 'server-only' turns an accidental client import into a build error rather than a bundle containing your session logic.

  • Wrap verification in React's cache so several call sites in one render cost one session query.

  • Return a DTO, not a row. Select only the columns you need, then project through per-field permission functions for the conditionally visible ones.

  • Server Actions and Route Handlers are independent entry points and inherit nothing from the page that rendered the form.

  • A static route shared between users has no per-request render, so a paywall on it must be enforced in the proxy.

Authorisation decides who may have data. The next chapter is about what happens to it afterwards — the ways data crosses the server/client boundary without anyone intending it, the tainting APIs that make that a runtime error, and why a Server Action's closure is encrypted. It is Data Security Across the Boundary.

Editorial reflection

The layout trap is the most interesting security problem in this book, because it is not a vulnerability in anything. Every component behaves as documented. The layout renders once and is reused — that is the feature, stated plainly, and it is a good feature. The auth check returns the right answer — it is correct code. The bug exists only in the seam, in an assumption inherited from a different architecture: that a component wrapping a route runs whenever that route is accessed. Nobody wrote that assumption down, which is exactly why it survived. What I take from it is a specific kind of suspicion worth cultivating. When a framework changes when your code runs, every security check whose correctness depended on the old timing is now unverified, and none of them will announce it — they will keep passing review, because reviewers check whether code is right rather than whether it runs. The questions that catch this are boring and unglamorous: how often does this execute, what would have to be true for it to be skipped, and would I notice. I have never regretted asking them and I have several times regretted not.

Q&A · 0

Enrol to ask questions and join the discussion.

No questions yet — be the first to ask.