Daniel Murillo
Index

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 + SameSite session cookie, start there. A JWT in localStorage is 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:

  1. Why, in web apps you control, should you prefer session cookies?
  2. Which risks does each practice mitigate (and which does it not)?
  3. 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?NoYes
Typical XSS impactThe attacker can act as the user in the browser, but does not easily exfiltrate the session ID for offline useThe attacker reads the token, copies it, and uses it from another machine until it expires
RevocationInvalidate the session on the server (immediate)You must wait for exp, rotate keys, or keep a denylist
SendingThe browser attaches it automaticallyYou 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%”.

PracticeThreat it targetsWhat you gainWhat it does not solve alone
Cookie SecureSession theft in transit (MitM / cleartext HTTP)The cookie is not sent over HTTPMisconfigured certificates, downgrade if you still serve HTTP
Cookie HttpOnlyExfiltration of the ID/token via JS (XSS → document.cookie / storage)The script cannot read the cookieXSS that acts as the user; CSRF
SameSite=Lax or StrictClassic CSRF (another site sends a POST with your cookie)Defense in depth against many cross-site CSRF casesSame-site CSRF (subdomains), state-changing GETs, old browsers
CSRF token (or double-submit / custom header)CSRF on mutationsProtection even if SameSite fails or is insufficientSame-origin XSS (the script can read the page’s CSRF token)
Opaque server sessionLong-lived stolen tokens; stale claimsCentral logout and revocationYou need a store and session scaling
Short access token + refresh in HttpOnly cookieTheft of a long-lived access tokenSmall abuse window; refresh is not readable by JSYou must rotate refresh and detect reuse
PKCE + Authorization Code (OAuth)Code interception/injection in public clientsModern flow for mobile/SPA with an IdPDoes not replace authorization in your API
Validate iss / aud / exp / alg on JWTsFake or misused tokens across APIsYou trust only the correct issuer and audienceA valid stolen JWT remains valid until exp
Rotate session ID on loginSession fixationThe attacker cannot “anchor” a prior sidLater XSS/CSRF
Do not put tokens in logs/URLsLeaks in history, proxies, APMLess leakage surfaceHuman 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 / prefixWhy
SecureHTTPS only
HttpOnlyJS cannot read the cookie
SameSite=LaxUX/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=None without Secure, and only if you truly need cross-site (there is almost always a better design).
  • SameSite does not replace a CSRF token on sensitive operations: OWASP treats it as defense in depth.
  • Ensure GETs do not change state (if they do, Lax will 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:

  1. Very short access token (minutes).
  2. Refresh in an HttpOnly cookie (if there is a browser) or secure storage (mobile).
  3. Refresh rotation and reuse detection.
  4. A revocation list or session version on the server for real logout.
  5. 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 SameSite fully replaces a CSRF token.
  • SameSite=None only 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

ContextStart hereYou mainly mitigateYou must also design
Same-site web (you control front and API)Session + HttpOnly/Secure/SameSite cookieSecret theft via XSS, revocationCSRF, session store, XSS
SPA + API on another domainBFF + same-site cookie to the BFFAvoid Bearer in JSAuthZ in the API, BFF trust
Mobile appAuth Code + PKCE + secure storageCode interceptionRefresh rotation, biometric/OS storage
Third-party APIOAuth/OIDCDelegation without sharing passwordsScopes, audits, revocation
Internal servicesmTLS / short workload tokensMachine credentialsRotation 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

  1. Authentication (this note)
  2. Observability: logs, metrics, and tracing
  3. SQL, query builders, and ORMs
  4. How to test an API
  5. Containers and Twelve-Factor

Related reading

References