React Auth

Auth in React Server Components: Where the Session Actually Lives

The App Router changed where authentication runs. Layouts that don't re-render, middleware that shouldn't authorize, the client/server boundary and what leaks across it, streaming and Suspense, and why every route must check for itself.

Emilian GheoneaJuly 15, 20269 min read

React Server Components did not change what authentication is. They changed where it runs, what a component can reach, and — most consequentially — which mental models silently stop being true.

If you are porting an SPA's auth to the App Router, the patterns you know mostly still compile and mostly still work. The ones that quietly do not are the interesting part, because they fail as authorization holes rather than as errors.

The client/server boundary is the whole thing

In an SPA, everything runs in the browser. Authentication is a fetch, the result goes in a context provider, and components read from it. The rule is simple: nothing is secret, so protect the API.

In the App Router, components run in two different places, and the boundary is a serialization boundary. Server Components run only on the server; they can read cookies, query the database, and use secrets. Client Components run in both — rendered on the server for the initial HTML, then hydrated and run in the browser.

The consequence people get wrong: props passed from a Server Component to a Client Component are serialized into the HTML payload. They are in the page source. Anyone can read them.

// Server Component
const user = await getUser();          // full record from the database
return <ProfileCard user={user} />;    // ← the ENTIRE record ships to the browser

If user contains a password hash, an internal role flag, a Stripe customer ID, or a two-factor secret, all of it is now in the HTML. This is the single most common security mistake in App Router applications, and nothing warns you about it.

The fix is a projection at the boundary — a deliberate, explicit shape:

const user = await getUser();
return <ProfileCard user={{ id: user.id, name: user.name, avatar: user.avatarUrl }} />;

Better still, define the safe shape once as a type and a mapping function, and never pass a database row across the boundary. The discipline generalizes: treat the Server-to-Client prop boundary exactly like an API response, because that is what it is.

Middleware is for routing, not authorization

Next.js middleware runs before a request reaches your route, which makes it look like the ideal place to enforce authentication. It is not, and the reasoning is worth understanding rather than memorizing.

Middleware runs in a constrained runtime. It typically cannot reach your database, so it can check whether a session cookie exists and perhaps verify a JWT signature — but it cannot check whether the session was revoked, whether the user still belongs to the organization, or whether their role still permits the action. It is working from a snapshot, if it has anything at all.

More importantly, middleware protects routes, and routes are not the only way data leaves your application. Server Actions, route handlers, and data fetched in a component that happens not to match your matcher are all paths that middleware may not cover. A matcher that is subtly wrong — and matchers are subtly wrong all the time — produces an unprotected route that looks protected.

The rule that holds:

Middleware performs optimistic redirects. Every route, Server Action, and data access performs its own real check.

Middleware bounces obviously-unauthenticated requests to the login page, which is a UX improvement. Authorization happens where the data is.

// middleware.ts — optimistic only
export function middleware(request: NextRequest) {
  const hasSession = request.cookies.has('session');
  if (!hasSession && request.nextUrl.pathname.startsWith('/dashboard')) {
    return NextResponse.redirect(new URL('/signin', request.url));
  }
  return NextResponse.next();
}
// Every page/action — the real check
export default async function DashboardPage() {
  const session = await requireSession();   // verifies, hits the store, redirects if invalid
  const data = await getDataFor(session.userId);
  // …
}

Layouts do not re-render, and that is a security problem

This one has caused real vulnerabilities, and it is entirely non-obvious.

A layout renders once and is preserved across navigations within its segment. Navigating from /dashboard/a to /dashboard/b re-renders the page, not the layout. So:

// app/dashboard/layout.tsx — DOES NOT PROTECT CHILD PAGES RELIABLY
export default async function DashboardLayout({ children }) {
  const session = await getSession();
  if (!session) redirect('/signin');
  return <Shell>{children}</Shell>;
}

The check runs on the first render. It does not necessarily run again on client-side navigation to a sibling route. A session that expires, or a role that is revoked, while the user is on the page does not trigger the layout's check on their next navigation.

Layouts are for shared UI. Authorization belongs in the page, in the Server Action, and in the data layer. If you find yourself relying on a layout check, you have a hole.

The pattern that actually scales is to push the check into the data access layer, so it is impossible to fetch without it:

// lib/data.ts
export async function getProjects() {
  const session = await requireSession();      // throws/redirects — not optional
  return db.query.projects.findMany({
    where: eq(projects.organizationId, session.organizationId),
  });
}

Now no page can forget, because there is no way to get the data without going through the check. This is the same principle as enforcing tenant_id in one place rather than in every query, from multi-tenant auth design.

Server Actions are public endpoints

A Server Action looks like a function call. It is an HTTP endpoint with a generated URL, and it can be invoked directly by anyone who finds it — no button, no form, no page.

'use server';

export async function deleteProject(projectId: string) {
  // Without a check here, this is an unauthenticated DELETE endpoint.
  const session = await requireSession();
  const project = await db.query.projects.findFirst({ where: eq(projects.id, projectId) });
  if (project?.organizationId !== session.organizationId) throw new Error('Not found');
  await db.delete(projects).where(eq(projects.id, projectId));
}

Three checks, all mandatory, none automatic: authenticate the caller, authorize them for this specific resource, and validate the arguments — which arrive from the client and are therefore untrusted, whatever the TypeScript signature says. Parse them with a schema validator rather than trusting the type.

The fact that the action is only rendered inside an admin-only component means nothing. The component is a UI detail; the endpoint is public.

Streaming, Suspense, and partial output

Streaming SSR sends HTML as it is produced. Once a chunk has gone to the browser, it cannot be recalled — which means an authorization check that happens after something has streamed is too late.

The practical implications:

Check before you stream anything sensitive. A redirect() called deep inside a suspended boundary, after the shell has been flushed, may not behave as you expect. Do the authorization check early, at the top of the page component, before rendering begins.

Do not put a <Suspense> boundary around your authorization gate. The point of Suspense is to show something while waiting. If what you are waiting for is "may this person see this page," showing something is exactly wrong.

Be careful with error boundaries. An authorization failure that throws inside a boundary renders the fallback — which may be a generic error page that has already leaked the surrounding layout, including navigation that reveals structure the user should not see.

The general shape: authorize synchronously with respect to the render, then stream the data.

Getting the session to Client Components

Client Components cannot read cookies(). They need the session state passed to them, and there are three approaches.

Props from a Server Component — the simplest, and correct for a single component. Just remember the projection.

A Server Component that renders a Client provider — read the session on the server, pass the minimal shape into a context provider at the top of the tree:

// app/layout.tsx (Server Component)
const session = await getSession();
return (
  <SessionProvider value={session ? { id: session.userId, name: session.name } : null}>
    {children}
  </SessionProvider>
);

This is the closest analogue to the SPA pattern and is fine — as long as everyone understands that the context value is display state, not authorization state. A Client Component reading role === 'admin' from context to decide whether to show a button is doing UI, not security. The button being hidden protects nobody; the Server Action behind it is what protects the data.

Fetching from a route handler — a /api/me endpoint the client calls. Useful when the session must stay live across long-lived client sessions, and it costs a round trip.

Caching will serve one user's page to another

This is the App Router failure with the worst consequences, and it is a configuration problem rather than a coding one.

Next.js caches aggressively by default, at several layers: the full route cache for statically rendered routes, the data cache for fetch results, and the router cache on the client. A page that renders per-user content but is not correctly marked as dynamic can be rendered once, cached, and served to everyone — including a signed-out visitor, and including a different customer.

The good news is that reading cookies() or headers() opts a route into dynamic rendering automatically, so a page that reads the session cookie is generally safe. The bad news is the ways that breaks:

A session read that is conditional. If the session is only read inside a branch that does not execute during the build, the route can still be statically rendered.

A cached fetch inside an authenticated route. The route is dynamic, but a fetch with default caching inside it can return another user's data, because the cache key is the URL — not the credential you attached to it. Any fetch that varies by user needs cache: 'no-store', or a cache tag scoped to that user.

CDN caching in front of the app. A Cache-Control: public on an authenticated response is a shared-cache poisoning waiting to happen. Personalized responses must be private, no-store, and your CDN configuration must agree.

The defence is a small amount of paranoia in the right places: mark authenticated route segments dynamic explicitly rather than relying on inference, set no-store on any user-varying fetch, and add an integration test that requests an authenticated page anonymously and asserts it does not contain another user's data. That test is worth more than any amount of reading the caching documentation.

Long-lived tabs and stale session state

Server Components give you a session snapshot at render time. The App Router then keeps the client alive for a long time, and the router cache means navigating back to a previously visited route may show a cached RSC payload rather than re-rendering.

So a user whose session was revoked five minutes ago can navigate around a dashboard that was rendered while they were still authorized. The server will reject their next Server Action or data mutation — which is the check that actually matters — but the UI will look logged in until then.

That gap is usually acceptable, provided you are deliberate about it:

Never let a stale render be the authorization. It is display. The mutation path re-checks, always.

Call revalidatePath or revalidateTag when authorization changes. Removing someone from an organization should invalidate the cached renders that assumed they were in it.

Handle the rejection well on the client. A Server Action that fails because the session ended should produce a clear "your session ended — sign in again" state, not a generic error toast. This is the moment the user finds out, so make it legible.

For the client-side half of this problem — refresh races, expiry during an in-flight request, and cross-tab coordination — see handling token expiry in SPAs.

The mental model that keeps you safe

Three sentences that cover most of it:

Everything that crosses to the client is public. Props, context values, serialized payloads. Project deliberately.

Every entry point checks for itself. Pages, Server Actions, route handlers. Middleware and layouts are conveniences, not controls.

Put the check where the data is. A data access layer that cannot be used without a session is the only structure that survives a new engineer adding a route on a Friday.

The embedded-auth case adds one more consideration: if your sign-in UI lives in a cross-origin iframe, the token arrives by postMessage and must be exchanged for a first-party session — which in the App Router means a route handler that verifies the token and sets an HttpOnly cookie, after which everything above applies normally. The reasoning is in why third-party cookies break embedded auth.

Key takeaways

  • Props to Client Components are public. Never pass a database row across the boundary; project to an explicit safe shape.
  • Middleware redirects optimistically; it does not authorize. It cannot see revocation, and matchers are wrong more often than you think.
  • Layouts do not re-render on sibling navigation. Never put your only auth check in one.
  • Server Actions are public HTTP endpoints. Authenticate, authorize the specific resource, and validate arguments with a schema.
  • Authorize before you stream. Streamed HTML cannot be recalled.
  • Session in client context is display state. Hiding a button is not a security control.
  • The durable pattern is a data access layer that cannot be called without a session check.

Written by

Emilian Gheonea

Senior Blockchain & Full-Stack Software Engineer. I build EmbedAuth — an embeddable authentication platform for SaaS — and write about the auth problems most teams hit too late.