Skip to main content
Session vs Token Authenticationlesson 4 of 4 · 3 min read

CSRF and Storage Pitfalls

The feature that is also the vulnerability

Cookies have one behaviour that is both the feature and the vulnerability. Your browser attaches them automatically to every request for their domain, no matter whose page triggered that request.

Picture the attack. A page on some other site contains a hidden form pointing at your bank's transfer endpoint and submits itself on load. The browser dutifully attaches the bank cookie, and the bank sees a well-formed authenticated request.

The attacker never read the cookie. They simply spent it.

Run at least two of the three defences. Marking your cookies as same-site stops the browser attaching them to cross-site posts. Browsers making that the default in 2020 quietly killed the classic drive-by version of this for most of the web.

Put a random value in each form or header that an attacker's page cannot read, which your framework middleware handles for you. And check the origin header, rejecting anything initiated from a foreign site outright.

Header tokens, and their own cost

Understand why tokens in a header are immune to this. Nothing attaches them automatically, so a hostile page cannot spend what it cannot set.

Read the relocated cost that comes with that honest advantage. Your token now has to live somewhere scripts can reach, and browser storage is readable by any script running on your page. One injection hole, one compromised dependency, and every user's token is exfiltrated for later use.

Compare a cookie marked unreadable by script, which cannot be read at all. An injection can still fire requests as your user while the tab is open, since nothing fully survives that. The credential itself cannot be carried away and replayed from somewhere else.

Build the defensible browser setup. Credential in a cookie that is unreadable by script, secure and same-site. Cross-site protection on anything that changes state. And a real content security policy.

Fall back carefully if you must use header tokens. Hold the access token in memory only, keep the refresh token in a cookie script cannot read, and reserve browser storage for things you would not mind an attacker holding.

the shape of it
Victimevil.examplehidden auto-formbank.comsees valid cookie1. opens page2. POST /transfer3. 200 OK
step 1 of 3
The browser attaches the bank cookie to the forged request automatically; the attacker spends a credential they never see.
where the credential lives decides which attack works
Java
// Cookie the browser attaches automatically. Immune to script
// theft, exposed to cross-site requests, so it needs SameSite.
res.cookie("session", sid,
    new Cookie().httpOnly(true)     // script cannot read it, even via XSS
                .secure(true)       // never sent over plain HTTP
                .sameSite("Lax"));  // not attached to cross-site POSTs

// Header token. Immune to cross-site requests, because nothing
// attaches it for you. Exposed to any script that runs on your page.
localStorage.setItem("token", jwt);   // one XSS and it is exfiltrated

// The defensible browser setup: credential in the cookie above,
// cross-site protection on anything that changes state, and a real
// Content-Security-Policy so the script never runs in the first place.

Worked example

In 2008, Princeton researchers William Zeller and Ed Felten published working CSRF attacks against four production sites, the worst being ING Direct, then one of the largest US online banks. Their proof of concept was a page that, when visited by a logged-in ING customer, silently created a new account in the victim's name and transferred money into it, then out to an account the attacker controlled, every step a forged POST that the browser authenticated by attaching the victim's session cookie. No password was stolen and no TLS was broken; the browser did exactly what cookies are designed to do. ING fixed it within days of disclosure, and the same paper documented holes in NYTimes.com and YouTube. SameSite cookies didn't reach browsers until roughly 2016, so for years, per-request CSRF tokens were the only wall, and the incident is why frameworks now refuse to let you forget them.

Session vs Token Authentication: wrapping up

In the real world

  • 01Facebook's September 2018 breach response invalidated tokens for 90 million accounts in one operation, a revocation capability that exists only because tokens were checked server-side on use.
  • 02Chrome 80 (February 2020) made SameSite=Lax the default cookie behavior, with Edge and Firefox following, which eliminated the classic auto-submitting-form CSRF for most sites overnight.
  • 03Django and Rails ship server-side sessions and CSRF middleware enabled by default, which is why classic CSRF findings cluster in hand-rolled API backends rather than framework apps.
  • 04GitHub runs both models at once: revocable cookie sessions with a per-device session list for the website, and personal access tokens plus OAuth tokens for the API.
  • 05Auth0 made refresh token rotation with reuse detection its recommended default for single-page apps, precisely because SPAs have nowhere safe to store a long-lived credential.

Questions people ask

Which should I pick for a standard web app?

Server-side sessions, via your framework's built-in middleware. You get instant revocation, per-device session lists, and battle-tested CSRF protection for free, and a Redis session store scales further than most products ever need. Reach for tokens when the topology demands them: mobile clients, many services verifying independently, or cross-domain APIs.

If I put a JWT in an httpOnly cookie, is that session or token auth?

It's token authentication using a cookie as the transport. The server still verifies a signature statelessly rather than looking up a session record. But because the browser now attaches the credential automatically, you inherit cookie problems too, so you need SameSite and CSRF defenses on top of the token's own expiry handling.

Is localStorage ever acceptable for tokens?

For a credential whose theft you care about, no; any XSS or compromised third-party script reads localStorage and ships the token out. If you must keep tokens in JavaScript-land, hold the short-lived access token in memory only and keep the refresh token in an httpOnly cookie. The standard recommendation remains httpOnly cookies for anything long-lived.

Quick review

Session-based:
server stores session in DB/Redis. Client sends session ID cookie. Easy to invalidate, scales with shared session store
Token-based (JWT):
server stores nothing. Token carries all info. Client sends in Authorization: Bearer header
Session invalidation:
delete row from sessions table → instant logout. JWT invalidation requires blocklist
Scaling sessions:
requires sticky sessions OR a shared session store (Redis) accessible by all servers
CSRF attack:
session cookies sent automatically by browser → must add CSRF token. JWTs in headers are CSRF-safe
XSS attack:
JS can steal localStorage → never store sensitive tokens in localStorage. httpOnly cookies can't be read by JS
Best practice:
httpOnly + Secure + SameSite=Strict cookie for tokens protects against both XSS theft and CSRF
the trade-off

Sessions need shared store for horizontal scale. JWTs sacrifice instant revocation for stateless simplicity.

in the room

Sessions for traditional web apps with server-side rendering. JWTs for APIs, mobile clients, microservices.