The first time you build a single-page app, auth feels solved. The user logs in, the API hands you a token, you store it somewhere on the client, and you attach it to every fetch. The whole thing fits in a paragraph.

The first time you put that same app behind server-side rendering, auth stops feeling solved. The page is now rendered in two places — once on a Node server before the user ever sees pixels, then again in their browser after hydration. Your token, sitting in localStorage, is invisible to the server. The dashboard renders as logged-out HTML. The browser hydrates, reads localStorage, realizes the user is logged in, and re-renders. The user sees a flash of the wrong page.

That flash is the symptom. The cause is that you are storing identity in a place only one of the two runtimes can see. Modern auth is about putting identity somewhere both runtimes can read, and routing the long-lived secrets through the layer that can keep them hidden.

Cookies Are The Only Storage Both Runtimes Share

The browser sends cookies on every request to your origin, including the very first HTML request. That means your server-side renderer — Next.js, Remix, SvelteKit, Nuxt — gets the user's session before it renders a single tag. No spinners, no flashes, no double-render. The component just knows.

TypeScript
// app/dashboard/page.tsx
import { cookies } from 'next/headers';
import { redirect } from 'next/navigation';
import { getSession } from '@/lib/auth';

export default async function Dashboard() {
  const session = await getSession(cookies());
  if (!session) redirect('/login');

  return <h1>Welcome back, {session.user.name}</h1>;
}

The cookie itself should be HttpOnly, Secure, and SameSite=Lax (or Strict if your auth flow does not need cross-site nav). HttpOnly keeps JavaScript out of it, so an XSS payload cannot read or exfiltrate the session. Secure keeps it off plain HTTP. SameSite=Lax neutralizes the most common CSRF vectors while still letting top-level navigations work.

Http
Set-Cookie: session=eyJ...; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=3600

localStorage for tokens is the wrong default for SPAs, and a complete dead end for SSR. Stop using it.

Sessions Versus JWTs: Both Are Valid, Pick One

There are two reasonable shapes for the cookie's contents:

  1. An opaque session ID that points at server-side state (a row in your sessions table, a record in Redis). Lookups cost a database call per request. Revocation is instant — delete the row, the session is dead. This is the boring, correct default for most products.
  2. A signed JWT carrying the claims directly. No lookup per request, just signature verification. Revocation is harder: the token is valid until expiry unless you maintain a deny-list. Useful when you have a stateless API that wants to verify identity without hitting your auth database.

Most products end up with both: a short-lived JWT used as the access token between services, plus a long-lived opaque session cookie for the browser, with the session row holding the refresh token. The session cookie is what keeps the user logged in for weeks; the JWT is what flows through the system for a few minutes at a time.

The wrong move is putting a long-lived JWT directly in a cookie and never rotating it. If it leaks, the attacker has weeks of access.

Authentication Versus Authorization

Authentication asks: who are you? Authorization asks: are you allowed to do this? They live in different places.

The session cookie answers authentication — once. Authorization is per-request, per-resource. A user who can edit their own posts cannot edit another user's, even though both are "logged in." That check belongs as close to the data as possible:

TypeScript
const post = await db.post.findUnique({ where: { id } });
if (!post) return notFound();
if (post.authorId !== session.userId) return forbidden();

Centralizing authorization at the edge ("middleware checks if you are an admin") works for coarse routing decisions. For row-level decisions, you need the check next to the query. Frameworks that try to do all of this in middleware end up either too permissive or unable to express the rules at all.

OAuth, OIDC, And Why You Probably Use Both

For social login and enterprise SSO, OAuth 2.0 (authorization) plus OpenID Connect (identity) are the standards every serious provider speaks. SPAs in particular should use the Authorization Code flow with PKCE — no client secret in the browser, the code-to-token exchange happens with a one-time code verifier that only the client knows.

The provider redirects the user to your callback URL with a code query parameter. Your server (not the browser) exchanges that code plus the PKCE verifier for an access token, ID token, and refresh token, and writes them into a server-side session. The browser walks away with a session cookie; the OAuth tokens never touch client JavaScript.

Auth.js (the rebranded NextAuth, with v5 in long-running beta and stewardship folded into the Better Auth project in 2025), Clerk, WorkOS AuthKit, and better-auth are the libraries most teams reach for. They handle the dance — redirects, code exchange, session persistence, refresh — so you can focus on the parts that are actually about your product.

A note from 2025: Lucia was archived in March 2025. Existing apps are fine; new projects should not start there. The maintainer has been clear about preferring you build directly on the primitives (oslo, arctic) or use Auth.js / better-auth instead.

A diagram showing the BFF (backend-for-frontend) auth pattern. The user&#39;s browser holds only an HttpOnly session cookie. Requests go to a Next.js BFF, which reads the cookie, looks up the session, attaches a short-lived access token as a Bearer header, and forwards the request to a separate upstream API. The upstream API never sees the cookie. On a 401, the BFF uses a refresh token to mint a new access token, retries the request, and writes the rotated cookies back to the browser. A second panel shows where each token type lives: cookie in the browser, session row in the BFF database, refresh token only in the BFF, JWT in flight between BFF and API.
Identity in the browser, secrets on the server, BFF in between

The BFF Pattern Is The One That Scales

The hardest version of this problem is when your frontend and your API are separate services on separate domains — Next.js on app.example.com, a Go or Rust API on api.example.com. Cookies are domain-scoped. Cross-domain cookie behavior is increasingly restricted by browsers, especially for third-party contexts. CORS adds another layer.

The pattern that holds up: backend-for-frontend. Your Next.js (or Remix, or SvelteKit) server is the browser-facing API. Client components call /api/* routes on your own origin. Those routes read the session cookie, look up the access token from the server-side session store, and forward the request to the upstream API as a regular Authorization: Bearer ... header.

TypeScript
// app/api/users/me/route.ts
import { cookies } from 'next/headers';
import { getSession, refreshAccessToken } from '@/lib/auth';

export async function GET() {
  const session = await getSession(cookies());
  if (!session) return new Response('Unauthorized', { status: 401 });

  let res = await fetch('https://api.example.com/users/me', {
    headers: { Authorization: `Bearer ${session.accessToken}` },
  });

  if (res.status === 401) {
    const refreshed = await refreshAccessToken(session);
    res = await fetch('https://api.example.com/users/me', {
      headers: { Authorization: `Bearer ${refreshed.accessToken}` },
    });
  }

  return new Response(await res.text(), {
    status: res.status,
    headers: { 'content-type': res.headers.get('content-type') ?? 'application/json' },
  });
}

The browser never holds the access token, never holds the refresh token, never sees the upstream domain. CORS becomes a same-origin problem because everything goes through your own framework server. Refresh-token rotation happens server-side, where it belongs.

The cost is one extra network hop per call. In practice it is small — your BFF and your upstream API are usually in the same region — and it is more than paid back in security and operational simplicity.

Refresh Without Race Conditions

Access tokens should be short-lived: fifteen minutes is a reasonable default. When they expire, you swap them for a new one using the refresh token. Two pitfalls show up here.

First, refresh-token rotation: the auth provider issues a new refresh token alongside each new access token, and the old refresh token is invalidated. If a refresh token is reused, the provider treats it as a likely theft and revokes the entire chain. This is the OAuth 2.1 recommendation and most modern providers (Clerk, Auth0, Okta, Cognito) support it. Use it.

Second, concurrent refreshes. If three components fire simultaneous requests, all three see the same expired access token, and all three try to refresh at the same time, you race to swap the cookie three times and one of them ends up with a stale refresh token. The fix is a server-side lock per session — most BFFs implement this with a Redis lock or by serializing refreshes through a single in-memory promise per session ID.

A One-Sentence Mental Model

Put identity in an HttpOnly cookie that both your server and your browser can see, keep the long-lived secrets behind your own framework server using the BFF pattern, lean on a maintained library (Auth.js, Clerk, WorkOS, better-auth) for the OAuth dance, and let the upstream API see nothing but a fresh Authorization: Bearer header — that is the auth architecture that does not flicker, leak, or fall over.