Skip to content

Protecting a signed-in app in the App Router, in three layers

A layout that checks the session is not enough, because layouts do not re-render on client navigation. Where the proxy, the layout and the page each check, and why.

Next.js on Vercel4 min readships at docs/solutions/nextjs-vercel/auth-checks-in-an-app-shell.md

Tags: nextjs · auth · app-router · layouts · security

The first version of a signed-in area in the App Router usually looks like this:

// app/(app)/layout.tsx
export default async function AppLayout({ children }) {
  const user = await getUser();
  if (!user) redirect("/sign-in");
  return <Shell user={user}>{children}</Shell>;
}

One check, at the top, covering every page in the group. It reads like a guard around everything below it. It is not.

Why the layout check leaks

Layouts in the App Router are shared between the pages under them. When someone clicks from /dashboard to /projects, Next.js fetches the new page and keeps the layout it already rendered. The layout function does not run again. That is what makes navigation fast, and the Next.js authentication guide says it plainly: layouts "don't re-render on navigation, meaning the user session won't be checked on every route change".

There is a second, quieter reason. A layout does not decide whether the page under it renders. The router renders the segments, so even on a first load the page's code runs alongside the layout's, and a redirect thrown in the layout does not stop the page's queries from running.

So picture a session that ends while the tab is open: it expires, an admin revokes it, or the person signs out in another tab. The next click renders /projects on the server, and the only check between that page and your data is the one the layout did minutes ago. The page queries the database for a user who is no longer signed in.

It gets worse with a page that does its own lookup by id from the URL. If the page assumes "the layout checked, so there is a user", and then reads a record by params.id without checking who owns it, you have an authorisation hole that only shows up on client navigation. It never shows up in a test that loads the page directly.

Three layers, three jobs

1. The proxy (proxy.ts, once called middleware): the fast path. It runs before rendering, so a signed-out visitor to /dashboard gets a plain 307 to sign-in instead of a page that starts streaming and then redirects. On most setups it can only see that a session cookie exists, not whether the session is still valid: it runs on a lightweight runtime without your database. Treat it as a convenience. Deleting it must not make anything unprotected.

2. The layout: for the shell. The shell needs the user to show a name and an avatar, so it reads the session anyway. Let it redirect when there is none: that covers the first load of any page in the group. Do not treat it as the check for the pages under it.

3. The page: the real check. Every page that shows private data reads the session itself:

export default async function ProjectsPage() {
  const user = await requireUser("/projects");
  const projects = await listProjects(user.id);
  // ...
}

A page renders on every navigation, including client-side ones, so this check always runs. Same for every server action and route handler: each is a public endpoint that anyone can call without clicking your UI.

"But that is the same query three times"

It is not, if the session read is wrapped in React's cache:

export const getSession = cache(async () => auth.api.getSession({ headers: await headers() }));

cache memoises per request. The layout and the page render in the same request, so the second call returns the first call's result. Three layers cost one lookup.

Return paths

A redirect to sign-in should bring the person back where they were. The page knows its own path, so it passes it: requireUser("/projects"). The layout does not know the URL (layouts get no pathname), so have the proxy put it in a request header and read it there:

// proxy.ts
headers.set("x-pathname", request.nextUrl.pathname);

Validate it before you use it. A return path must start with a single /. //evil.example and /\evil.example are both read by browsers as another host, which turns your sign-in page into an open redirect. Check the path after normalising it (new URL(value, base).pathname), not only the input: /..//evil.example starts with one slash and folds to //evil.example.

Streaming changes the status code

If a loading.tsx sits above the layout that redirects, Next.js may already have sent a 200 and the loading UI by the time the redirect happens. The browser still ends up on sign-in (Next.js sends the redirect in the stream and a meta refresh), but the status is 200, not 307. Crawlers, uptime checks and curl see the difference. The proxy's redirect is what gives signed-out visitors a real 307; this is the other reason to keep it.

Checklist

  • The proxy redirects signed-out visitors for every protected prefix.
  • The layout reads the session for the shell and redirects when there is none.
  • Every page, server action and route handler checks again, with cache making it free.
  • Return paths come from the page or a proxy header, and are validated.
  • Hiding a nav link is cosmetic. The admin page behind it checks the role.