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."
CSRF double-submit, only where it's needed
Cookie-based auth reintroduces CSRF risk that a Bearer token in a header doesn't have (a browser attaches cookies automatically; it doesn't automatically attach a custom header). So: a second, JS-readable cookie carries a CSRF value, and the frontend echoes it back as a request header on every mutation. The server checks the two match. This only needs to guard mutations — reads don't need it — and it doesn't apply at all to pure Bearer-token clients, since they were never vulnerable to it in the first place.
CORS: allowlist, never wildcard, especially with credentials
Access-Control-Allow-Origin: * combined with credentials: true is a combination that should never exist — it effectively lets any site ride your users' sessions. The fix is an explicit origin allowlist with credentials: true, never a wildcard and never a reflected-origin shortcut that accepts whatever Origin header shows up.
TOTP 2FA for anything privileged
Any role with elevated access gets standard TOTP setup/enable/disable, required at login once enabled. Cheap to implement, disproportionately valuable for admin/privileged accounts specifically.
Migrating an existing Bearer-token app without breaking it mid-cutover
This is the part that actually made the pattern practical to adopt, not just design: when a live app already uses Bearer tokens, the migration goes dual-mode. Auth guards accept either the httpOnly cookie or the Authorization header; CSRF checks apply only to the cookie path. The login endpoint keeps returning the token in the response body (for existing Bearer clients) while also setting the cookie. Nothing breaks for existing integrations while the frontend migrates over incrementally.
The verification checklist, every time
Before calling this "done" on a given app:
- Login actually sets an httpOnly cookie (check response headers, not just "it seems to work").
- Cookie-based requests succeed without a manually attached token.
- A mutation request missing the CSRF header gets rejected (403), not silently allowed.
- A request from a disallowed origin gets no
Access-Control-Allow-Originheader at all. - Logout actually clears both cookies, not just the session server-side.
Skipping any one of those checks is how "we implemented CSRF protection" quietly becomes "we implemented a CSRF header nobody validates."
What I'd tell past-me
- Decide the auth pattern once, well, and reuse it — don't re-litigate localStorage-vs-cookie on every new project.
- The CORS+credentials wildcard combination is the single easiest way to accidentally build a session-hijacking vulnerability. Make it impossible to ship, not just documented as wrong.
- A migration plan that keeps old clients working is what actually gets a hardened pattern adopted on a live app — "just rewrite it" proposals die in planning meetings.
Top comments (0)