Architecture · October 28, 2026 · Daniel Murillo
Authentication: why prefer cookies (and what to do if you cannot)
If you control the web app, an HttpOnly cookie session usually mitigates XSS token theft better than a JWT in localStorage. When that is not enough and how to fix it.
If you can authenticate a web app with an
HttpOnly+Secure+SameSitesession cookie, start there. A JWT inlocalStorageis not “more modern”: it is usually easier to steal with XSS.
Introduction
Authentication and authorization are not the same:
- Authentication: proves who is making the request.
- Authorization: what that identity can do with a specific resource.
This article answers three practical questions:
- Why, in web apps you control, should you prefer session cookies?
- Which risks does each practice mitigate (and which does it not)?
- What to do when cookies are not enough (mobile, public API, cross-domain)?
1. The practical rule: cookies first when the client is your browser
Ideal scenario:
- You have a web frontend (SSR, SPA, or hybrid) and a backend that you operate.
- The user signs into your product (they are not only a third-party client).
- You can put frontend and API on the same site (
app.example.com) or behind a BFF.
In that case, the most solid design is usually:
Login → server creates opaque session
→ Set-Cookie: __Host-session=…; Secure; HttpOnly; SameSite=Lax|Strict
→ JS never reads the session secret
→ each same-site request carries the cookie automatically
→ logout = invalidate the session on the server
Why this is better than storing a JWT in localStorage:
Cookie HttpOnly (session) | JWT in localStorage / sessionStorage | |
|---|---|---|
| Can page JS read the secret? | No | Yes |
| Typical XSS impact | The attacker can act as the user in the browser, but does not easily exfiltrate the session ID for offline use | The attacker reads the token, copies it, and uses it from another machine until it expires |
| Revocation | Invalidate the session on the server (immediate) | You must wait for exp, rotate keys, or keep a denylist |
| Sending | The browser attaches it automatically | You must set Authorization by hand |
OWASP and browser-app guides discourage storing session identifiers or long-lived tokens in JavaScript-accessible storage precisely because of XSS.
Important: HttpOnly does not “cure” XSS. With XSS, the attacker can still fire requests from the victim’s browser (the cookie travels on its own). What it mitigates is offline theft of the secret. That is why you still need XSS prevention (CSP, sanitization, dependencies) and, for state-changing operations, CSRF defenses.
2. Which threat each practice mitigates
Orientative table. “Mitigates” does not mean “eliminates 100%”.
| Practice | Threat it targets | What you gain | What it does not solve alone |
|---|---|---|---|
Cookie Secure | Session theft in transit (MitM / cleartext HTTP) | The cookie is not sent over HTTP | Misconfigured certificates, downgrade if you still serve HTTP |
Cookie HttpOnly | Exfiltration of the ID/token via JS (XSS → document.cookie / storage) | The script cannot read the cookie | XSS that acts as the user; CSRF |
SameSite=Lax or Strict | Classic CSRF (another site sends a POST with your cookie) | Defense in depth against many cross-site CSRF cases | Same-site CSRF (subdomains), state-changing GETs, old browsers |
| CSRF token (or double-submit / custom header) | CSRF on mutations | Protection even if SameSite fails or is insufficient | Same-origin XSS (the script can read the page’s CSRF token) |
| Opaque server session | Long-lived stolen tokens; stale claims | Central logout and revocation | You need a store and session scaling |
Short access token + refresh in HttpOnly cookie | Theft of a long-lived access token | Small abuse window; refresh is not readable by JS | You must rotate refresh and detect reuse |
| PKCE + Authorization Code (OAuth) | Code interception/injection in public clients | Modern flow for mobile/SPA with an IdP | Does not replace authorization in your API |
Validate iss / aud / exp / alg on JWTs | Fake or misused tokens across APIs | You trust only the correct issuer and audience | A valid stolen JWT remains valid until exp |
| Rotate session ID on login | Session fixation | The attacker cannot “anchor” a prior sid | Later XSS/CSRF |
| Do not put tokens in logs/URLs | Leaks in history, proxies, APM | Less leakage surface | Human error and backups |
CSRF vs XSS (so you do not mix them up)
XSS: the attacker runs JS on YOUR origin
→ with localStorage: can STEAL the JWT
→ with HttpOnly cookie: cannot read it, but can USE the session in the browser
CSRF: the attacker tricks the browser into calling YOUR API from ANOTHER origin
→ the cookie travels alone → unwanted mutation
→ SameSite + CSRF token mitigate this
→ Authorization: Bearer is NOT sent automatically → classic CSRF drops
(but you lost HttpOnly protection if the Bearer lives in JS)
So the real trade-off is not “insecure cookies vs secure JWTs”, but:
- HttpOnly cookies: better against theft via XSS; you must design for CSRF.
- Bearer in JS: worse against theft via XSS; classic CSRF matters less because the header is not attached automatically.
In a modern web app with good SameSite and CSRF on mutations, the first path usually wins.
3. How to configure the cookie (serious minimum)
Set-Cookie: __Host-session=opaque-id; Path=/; Secure; HttpOnly; SameSite=Lax
| Attribute / prefix | Why |
|---|---|
Secure | HTTPS only |
HttpOnly | JS cannot read the cookie |
SameSite=Lax | UX/security balance; Strict if you can live without the cookie on external links |
Path=/ + __Host- | Avoids ambiguous cookies across subpaths/hosts; __Host- requires Secure and no Domain |
Operational rules:
- After login or privilege elevation: rotate the session ID.
- Logout: clear the cookie and invalidate the record in the store.
- Never use
SameSite=NonewithoutSecure, and only if you truly need cross-site (there is almost always a better design). SameSitedoes not replace a CSRF token on sensitive operations: OWASP treats it as defense in depth.- Ensure
GETs do not change state (if they do,Laxwill not save you).
4. When cookies are not enough (or do not apply)
A. SPA on app.com and API on api.com (cross-site)
The browser treats the API cookie as cross-site → you need SameSite=None; Secure or you stop sending cookies to the API.
Better way out: BFF (Backend for Frontend)
Browser ←same-site cookie→ BFF (app.example.com)
BFF ←token/credential→ internal API / IdP
The browser only talks to the BFF. The session lives in an HttpOnly SameSite cookie. The BFF calls the API with server credentials or tokens that never touch JS.
B. Native mobile app
There is no same first-party cookie model as in the web browser. Use Authorization Code + PKCE, store refresh in OS secure storage (Keychain / Keystore), short-lived access tokens. Do not copy the browser localStorage pattern.
C. Public API for third parties
There OAuth 2.0 / OIDC with scopes and registered clients is the frame. Your own web product can still use session+cookie; integrators use tokens under RFC 9700 rules (PKCE, etc.).
D. Microservices / machine-to-machine
Browser cookies do not apply. Workload credentials, mTLS, or short tokens between services. Do not reuse a long-lived user JWT as internal auth without limits.
E. “I need JWT because I am stateless”
Stateless is not a moral requirement. A session store (Redis) is usually cheap compared to the cost of revocation and stolen tokens. If you still want JWT:
- Very short access token (minutes).
- Refresh in an
HttpOnlycookie (if there is a browser) or secure storage (mobile). - Refresh rotation and reuse detection.
- A revocation list or session version on the server for real logout.
- Never put the refresh in
localStorage.
That is no longer “pure stateless JWT”: it is a deliberate hybrid.
5. Opaque session vs JWT: different pieces
Session: cookie = opaque ID → server holds userId, roles, expiry
JWT: token = signed claims → anyone with the key can verify
You can combine them: web session in a cookie, and toward internal services a short-lived JWT issued by your BFF. The mistake is treating JWT as an automatic session replacement and storing it where XSS can read it.
6. OAuth 2.0 / OIDC (when there is an IdP or third parties)
OAuth authorizes delegated access; OIDC adds identity (id_token).
Per RFC 9700:
- Public clients: Authorization Code + PKCE (mandatory in practice).
- Avoid Implicit and Resource Owner Password.
- Exact-match
redirect_uri; no open redirects.
In an SPA with an IdP, a BFF or HttpOnly cookie pattern for your app session is still preferable to leaving long access/refresh tokens in JS.
7. Example: session with cookie in Express
import express from "express";
import session from "express-session";
const app = express();
app.set("trust proxy", 1); // if you terminate TLS at the proxy
app.use(
session({
name: "__Host-session",
secret: process.env.SESSION_SECRET!,
resave: false,
saveUninitialized: false,
cookie: {
httpOnly: true,
secure: true,
sameSite: "lax",
path: "/",
maxAge: 1000 * 60 * 60 * 8,
},
}),
);
app.post("/login", async (req, res) => {
// verify credentials…
req.session.regenerate((err) => {
if (err) return res.status(500).end();
req.session.userId = "user_123";
res.status(204).end();
});
});
app.get("/me", (req, res) => {
if (!req.session.userId) {
return res.status(401).json({ message: "Unauthenticated" });
}
return res.json({ userId: req.session.userId });
});
Use a shared store in production (not MemoryStore). Add CSRF protection on POST/PUT/PATCH/DELETE.
8. Example: validating a JWT (API or service)
import { jwtVerify } from "jose";
const secret = new TextEncoder().encode(process.env.JWT_SECRET!);
async function verifyAccessToken(token: string) {
const { payload } = await jwtVerify(token, secret, {
algorithms: ["HS256"],
issuer: "https://auth.example.com",
audience: "my-api",
});
return payload;
}
Reject unexpected alg values. With OIDC, validate against the issuer’s JWKS. A valid stolen token remains valid until exp: that is why short lifetime matters.
9. Common mistakes
- Hours/days-long JWT in
localStorage“because the SPA needs it”. - Believing
SameSitefully replaces a CSRF token. SameSite=Noneonly to avoid redesigning domains.- Not rotating the session on login (fixation).
- Logout that only clears client storage and leaves the session alive.
- Access token in query strings or logs.
- Confusing “hiding the button” with authorization over the resource.
- Using the same user JWT for auth between microservices without scopes or audience.
10. Decision matrix
| Context | Start here | You mainly mitigate | You must also design |
|---|---|---|---|
| Same-site web (you control front and API) | Session + HttpOnly/Secure/SameSite cookie | Secret theft via XSS, revocation | CSRF, session store, XSS |
| SPA + API on another domain | BFF + same-site cookie to the BFF | Avoid Bearer in JS | AuthZ in the API, BFF trust |
| Mobile app | Auth Code + PKCE + secure storage | Code interception | Refresh rotation, biometric/OS storage |
| Third-party API | OAuth/OIDC | Delegation without sharing passwords | Scopes, audits, revocation |
| Internal services | mTLS / short workload tokens | Machine credentials | Rotation and least privilege |
Conclusion
For a web application you control, preferring well-configured session cookies is not nostalgia: it reduces XSS impact on the authentication secret and simplifies logout. JWT and OAuth remain useful pieces —between services, with IdPs, on mobile— but they are not an excuse to leave a long-lived Bearer where any page script can read it.
If you can do same-site + HttpOnly cookie, do it. If you cannot, redesign with a BFF or short tokens + secure storage; do not “solve” cross-domain by dumping the token into localStorage.
In this series
- Authentication (this note)
- Observability: logs, metrics, and tracing
- SQL, query builders, and ORMs
- How to test an API
- Containers and Twelve-Factor
Related reading
- Tool: which backend fits you
- Tool: which API protocol fits you
- Optimizing REST APIs
- How to test an API