Building Authentication Into a Full-Stack App Without Reinventing Sessions Badly

TIL that storing a JWT in localStorage for an app that also needed server-rendered authenticated pages was the wrong call, and cookie-based sessions were less work, not more.

2 min read

Sabin Shrestha

Full-Stack Developer — Next.js, React & React Native

The Problem #

The first pass at auth issued a JWT on login, stored it in localStorage, and attached it to API requests from the client. It worked for client-rendered screens, but any server-rendered page that needed to know "is this user logged in" had no clean way to read it — localStorage isn't available during server rendering.

Context #

The app mixed server-rendered pages (for SEO and first-load performance) with client-side interactivity, which meant auth state needed to be readable on both sides, not just in the browser.

What I Tried #

Passing the token through a query param on server-rendered navigations so the server could read it, then re-storing it client-side after the page loaded.

What Went Wrong #

Tokens in URLs end up in browser history, server logs, and the Referer header — a real leak surface for something that's supposed to prove identity. It also meant every server-rendered route needed bespoke logic to extract and validate a token from a query string instead of a consistent auth layer.

The Solution #

Switched to an HTTP-only, secure cookie set on login. The browser sends it automatically on every request — server-rendered or client-side — without any app code having to manually attach it, and JavaScript can't read it, closing off a common XSS token-theft path.

res.cookie("session", token, {
  httpOnly: true,
  secure: true,
  sameSite: "lax",
  maxAge: 7 * 24 * 60 * 60 * 1000,
});

Why It Works #

Cookies are a browser-native mechanism for "attach this to every request to this origin," which is exactly the property server-rendered auth needs and localStorage doesn't have. Making it httpOnly also removes an entire class of token-theft bugs for free, since client-side JS — including any third-party script that gets injected — simply can't read it.

Lessons Learned #

localStorage felt simpler because it's directly readable from client JS, but "directly readable from client JS" is a security liability for a session token, not a convenience.

What I Would Do Differently #

I'd design auth around whichever rendering mode has the stricter requirements (server-rendered, in this case) from the start, instead of building for the client-only case first and retrofitting the server-rendered one.

HTTP-only cookies, XSS vs. CSRF trade-offs, session vs. token-based auth.