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.
A client asked me to audit their Next.js app. Revenue dashboard, user profiles, payment history — standard B2B SaaS. I opened DevTools, checked Application > Local Storage, and found the access token. Plain text. No prefix, no encryption.
I opened the Network tab, filtered by "script," and counted 16 third-party scripts running on the login page. Analytics, chat widgets, A/B testing, session recording. Any one of them could exfiltrate that token in one line. The app would have no way to detect it.
The fix wasn't a better obfuscation library. It was removing the token from the browser entirely.
The browser is not a vault. It's a rendering surface. It executes arbitrary JavaScript from dozens of sources — your code, third-party scripts, npm packages with 47 transitive dependencies. localStorage is readable by every line of JavaScript running on the page. XSS is not a theoretical attack. It's been a real vulnerability for 25 years.
HttpOnly cookies cannot be accessed by JavaScript. The browser sends them with every request to your domain, and JS cannot read them. This is enforced by the browser's security model, not by your configuration.
The architecture: browser sends request → server reads HttpOnly cookie → server validates session → server renders response. The browser never touches the token.
(cookie sent automatically)
Access tokens expire 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 — it runs at the edge before any page renders:
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();
response.cookies.set("access_token", result.accessToken, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "strict",
maxAge: 60 * 15,
path: "/",
});
request.cookies.set("access_token", result.accessToken);
response.headers.set("x-middleware-request-cookie", request.cookies.toString());
return response;
}
return NextResponse.next();
}The user never sees an expired token. The refresh token is itself an HttpOnly cookie. When the access token expires, middleware silently refreshes it before the page renders.
SameSite controls when cookies are sent with cross-site requests. None sends them everywhere — required for embeds, but your auth cookie follows the user to any site. Lax sends them with top-level navigations — default in modern browsers, protects against most CSRF. Strict only sends them for same-site requests — maximum protection, clicking a link from email won't include the cookie. For internal clinic dashboards and API-driven apps, Strict is the right choice.
HttpOnly cookies solve the client-side exposure problem. They don't solve server-side session management. If you validate a JWT on every request, you have stateless authentication — the most scalable approach for serverless environments. The tradeoff: you can't instantly invalidate a JWT before it expires. For most apps, this is acceptable. For high-security contexts, pair it with a Redis blocklist.
In Soff.ia's dashboard — where clinic owners control patient data — we use 15-minute access tokens with a Redis blocklist checked on sensitive writes. One Redis lookup per mutation. Instant session invalidation as the tradeoff.
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, read on the server, and arrive at the browser as rendered HTML.
In Next.js with App Router, most state that developers reflexively put in useState can live in the URL (via searchParams) or be fetched on the server. This is not a performance tradeoff. For authenticated applications, server-side rendering of user-specific data is faster than loading a skeleton, fetching client-side, and filling in the content.
Store tokens in HttpOnly cookies. Never in localStorage, sessionStorage, or a JavaScript variable that outlives a render cycle. Rotate tokens silently in middleware. Use short expiry. Pair with a server-side blocklist if you need instant invalidation.
The browser renders. The server authenticates. These responsibilities don't overlap.