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.
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.
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.
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.
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.
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.
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 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.
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.
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.
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
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.
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.
Letting an optimistic check decide something consequential. A cookie says what was true when it was issued, not what is true now.
Relying on a habit of checking rather than a structure that checks. A habit holds until the next rushed data function.
Omitting
import 'server-only'from the session module. That line turns an accidental client import into a build error instead of a shipped bundle.Skipping
cache()on the verification function. Without it, every call site is another session query in the same render.Passing a whole database row to a client component. Every column in it is now published, including the ones nobody read.
Assuming a Server Action inherits the page's authorisation. It is an independently reachable endpoint and must verify for itself.
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.
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
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.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.
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
cacheso 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.
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.