I stopped deciding auth architecture from scratch on every new project. After enough rounds of the same debate — "can we just put the JWT in localStorage for now?" — I settled on one hardened pattern and now I just apply it every time, tweaking only where the app needs a Bearer-token escape hatch (mobile clients, third-party API consumers).

Here's the pattern, and why each piece is there.

The core decision: httpOnly cookie, never JS-readable storage

The session token gets issued in an httpOnly + Secure + SameSite=Lax cookie, domain-scoped so it works across subdomains. It never touches localStorage, sessionStorage, or any JS-readable variable.

The reason is simple: anything readable by JavaScript is readable by an XSS payload. An httpOnly cookie is invisible to document.cookie and to any script running on the page, malicious or not. It's not a nice-to-have — it's the difference between "one XSS bug leaks a session" and "one XSS bug leaks nothing session-related."