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.
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.
Related Concepts #
HTTP-only cookies, XSS vs. CSRF trade-offs, session vs. token-based auth.
Related content
Auth Context for Next.js (Cookie-Based Sessions)
A React context wrapping an API — login, logout, signup, and password reset — that keeps the client in sync with an httpOnly session cookie by revalidating on mount.
Serving Premium Downloads Without Exposing the File's Real Storage URL
TIL how to replace a guessable direct-download link with a permission-checked, time-limited presigned URL, so the object storage path a purchased asset actually lives at is never sent to the client.
Live Poll Results Without WebSockets: Server-Sent Events in a Node.js API
TIL that once vote writes were race-free, the results screen still felt dead until a manual refresh — switching from client polling to Server-Sent Events made results update instantly without the bidirectional complexity WebSockets would have added for a channel that only ever sends in one direction.