Auth, Session & Storage

JWT vs opaque tokens, refresh rotation, and the XSS-vs-CSRF storage trade-off.

1The security triangle: you can't win all three

XSS — Cross-Site Scripting

An attacker injects malicious JavaScript into your page — via a user input, a CDN script, or a compromised npm package. That script runs with the same privileges as your code and can read anything JS can read: variables, DOM, localStorage, cookies (unless HttpOnly).

CSRF — Cross-Site Request Forgery

A malicious page tricks the victim's browser into making a request to your server. The browser auto-attaches cookies to that request, so the server sees a valid session. The attacker never reads the response — they just trigger state-changing actions (transfer money, change email) on the victim's behalf.

Every token storage decision is a trade-off among three properties. You can satisfy two of them, never all three simultaneously:

🔵 XSS-resistant (JS can't read it) + 🟡 JS-accessible (can set Auth header) = impossible

Why these two can never fully coexist: XSS is injected JavaScript running in your page's context. It can read JS variables, call your module's functions, and access anything your own code can. There is no way to make something readable by your JS but invisible to the attacker's JS. The only true XSS-resistant storage is HttpOnly cookie — the browser withholds it from JS entirely, including injected JS. Memory variables reduce blast radius (token dies with the tab, can't be read cross-origin) but do not prevent theft during an active attack.

Given that constraint, there are exactly three real options — pick the pair that fits your threat model:

Option A — limit XSS blast radius
🟢 CSRF-resistant 🟡 JS-accessible
Memory (JS module var) Access token lives in a variable. Dies when the tab closes.

⚠ XSS can still read a memory variable — but only during that live session. It cannot be exfiltrated to localStorage and replayed later. The win is limited blast radius, not invisibility.

✗ No persistence — page refresh = logged out
Option B — survive XSS + CSRF, stay logged in
🔵 XSS-resistant 🟢 CSRF-resistant
HttpOnly cookie + SameSite=Lax HttpOnly = JS-blind (XSS can't steal it). SameSite=Lax = browser won't send it on cross-site requests (CSRF can't trigger it). Together: both threats neutralised.
✗ Not JS-readable — server reads the cookie directly, no Bearer header.
Option C — JS-accessible, avoid CSRF
🟢 CSRF-resistant 🟡 JS-accessible
localStorage / sessionStorage JS reads it and sets Authorization: Bearer. Not auto-sent by browser = no CSRF.
✗ Any XSS anywhere in the app steals the token — permanently.
★ The winning pattern — split the problem across two tokens
Access token (15 min)
🟢 CSRF-resistant  🟡 JS-accessible
Memory — JS var / React stateXSS can read it, but it expires in 15 min and dies with the tab — limited blast radius. Set as Bearer header.
Refresh token (7–30 days)
🔵 XSS-resistant  🟢 CSRF-resistant
HttpOnly cookie, SameSite=Lax, Path=/auth/refreshHttpOnly = XSS-blind. SameSite=Lax = won't send cross-site. Path scope = only reaches the refresh endpoint.

You get XSS-resistance on both tokens, CSRF-resistance via SameSite, and JS-accessibility for the short-lived access token. You accept that access tokens die on tab close — the refresh cookie silently re-issues one.

No single mechanism wins everything. The question is: which threat is more dangerous in your context? For most web apps, XSS is the greater risk — an attacker who can run JS can exfiltrate a localStorage token and replay it from anywhere. That's why the modern recommendation is HttpOnly cookies for refresh tokens, in-memory for access tokens.

One-liner

Store the access token in memory, the refresh token in an HttpOnly cookie — XSS can't steal what JS can't read, and a 15-minute access token window limits blast radius.

2Client storage options compared

Storage XSS risk CSRF risk Persists across refresh Capacity Best for
Memory (module var / React state) Low* None No Unlimited Short-lived access tokens
HttpOnly Cookie None (JS-blind) Mitigated by SameSite Yes (per expiry) 4 KB Session IDs, refresh tokens
localStorage High None (not auto-sent) Yes (forever) ~5–10 MB Non-sensitive preferences
sessionStorage High None No (tab-scoped) ~5 MB Per-tab ephemeral state
IndexedDB High None Yes Hundreds of MB Offline data, large structured data

* Memory is not XSS-safe — injected JS runs in your page's context and can read variables. The advantage over localStorage is blast radius: the token is not persisted, dies with the tab, and cannot be read cross-origin or after the XSS is patched. A stolen memory token buys an attacker at most 15 minutes in one live session.

Trap — localStorage is not "fine for most apps": Any XSS vulnerability anywhere in your app (including third-party scripts) can read localStorage. The attacker copies the token and uses it from a different machine — forever until expiry. An HttpOnly cookie can't be read by that same script. The counter-argument: "you already lost if you have XSS" — true for full account takeover, but token theft enables silent long-term session riding even after the XSS is patched.

3JWT vs opaque tokens

JWT (JSON Web Token)

Stateless / Scale

Self-contained: header.payload.signature. Server verifies signature cryptographically — no database lookup. Claims (userId, roles, expiry) live in the payload. Signed with RS256 (asymmetric) or HS256 (symmetric).

Opaque Token

Revocable / Flexible

Random string. Server stores token → session data mapping in a database or Redis. Every request = a DB lookup. Instantly revocable: delete the row and the token is dead.

PropertyJWTOpaque
Verification costCrypto (fast, no I/O)DB/Redis lookup
RevocationCan't revoke before expiry*Instant (delete row)
Horizontal scaleStateless — any server verifiesShared store required
Payload visibleYes (base64 decoded, not encrypted)No
Token size200–400 bytes typical16–32 bytes
Typical useShort-lived access tokens (15 min)Long-lived refresh tokens, sessions

* JWT revocation workarounds: short expiry (15 min window), a blocklist in Redis (defeats statelessness for those tokens), or refresh-on-every-request. None is perfect. The practical answer: keep JWTs short-lived and accept the window.

JWT anatomy: the three parts

A JWT is three Base64URL-encoded strings joined by dots: header.payload.signature

eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ1c2VyMTIzIiwicm9sZXMiOlsiYWRtaW4iXSwiZXhwIjoxNzE2MjQyNjIyLCJpYXQiOjE3MTYyMzkwMjJ9.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
■ HEADER ■ PAYLOAD ■ SIGNATURE

1 · Header

Algorithm + token type
{
  "alg": "RS256",
  "typ": "JWT"
}

Tells the verifier which algorithm to use. RS256 = asymmetric (private key signs, public key verifies). HS256 = symmetric (same secret on both sides).

2 · Payload

Claims — NOT encrypted
{
  "sub": "user123",
  "roles": ["admin"],
  "exp": 1716242622,
  "iat": 1716239022
}

Base64URL encoded — anyone can decode it. Run atob(payload) in DevTools and read it in plain text. Never put passwords or sensitive PII here.

3 · Signature

Tamper-proof seal
RS256(
  base64url(header)
  + "."
  + base64url(payload),
  privateKey
)

Proves the header + payload were not modified after signing. It does not hide them — it only detects tampering. Verified with the public key (RS256) or the shared secret (HS256).

How a JWT is created (RS256)

Signing — done by the auth server
1
Build header JSON {"alg":"RS256","typ":"JWT"} → Base64URL encode → H
2
Build payload JSON with claims (sub, roles, exp, iat) → Base64URL encode → P
3
Compute signature: S = Base64URL( RS256( H + "." + P, privateKey ) )
4
Concatenate: JWT = H + "." + P + "." + S — hand it to the client.

How a JWT is verified (RS256)

Verification — done by every API server (no DB lookup needed)
1
Split JWT on "." → extract H, P, S
2
Recompute: expected = Base64URL( RS256( H + "." + P, publicKey ) )
3
Timing-safe compare expected === S. If mismatch → reject (token was tampered with or signed with a different key).
4
Decode P → check exp (not expired), iss (correct issuer), aud (correct audience). Any failure → reject.
5
All checks pass → trust the claims (sub, roles) and serve the request. Zero DB calls.

RS256 (asymmetric) — prefer this

Microservices / distributed

Auth server holds the private key (secret). Every other service only needs the public key — safe to distribute. A compromised API server can't forge new tokens because it never had the private key.

HS256 (symmetric)

Single-service / simple

Same secret signs and verifies. Every service that needs to verify JWTs must hold the secret — if any of those services is compromised, the attacker can forge tokens for any user.

Trap — "JWT is encrypted": It is not. Base64URL is a text-encoding scheme, not encryption. Paste any JWT into jwt.io or run JSON.parse(atob(token.split('.')[1])) in a browser console — you'll read the full payload in plain text. The signature proves the payload was not tampered with; it does not hide it. If you need the payload to be secret, use JWE (JSON Web Encryption) — a separate, rarer standard.
One-liner

JWT = fast, can't be killed early. Opaque = one DB row delete = instant revoke. Use both: JWT for the 15-min window, opaque in the DB for the kill switch.

4Refresh token rotation

Refresh tokens are long-lived and therefore the highest-value theft target. Rotation limits the damage window if one is stolen.

Refresh Token Rotation Flow
1
Login → server issues short-lived access token (15 min) + refresh token (7 days). Refresh goes in HttpOnly Secure cookie.
2
Client stores access token in memory. Uses it as Authorization: Bearer <jwt> on API calls.
3
Access token expires (or client pre-emptively detects near-expiry). Client calls POST /auth/refresh — cookie is auto-sent.
4
Server validates the refresh token, issues a new access token + a new refresh token, invalidates the old refresh token in the DB.
5
Reuse detection: if the old (already-rotated) refresh token is presented again → a stolen copy is in use → revoke the entire token family for this user.
Trap — the singleton promise does NOT fix the cross-tab race: The singleton is a JS module variable — it lives in one tab's isolated heap. Tab B has its own refreshPromise = null. When Tab A and Tab B both detect expiry simultaneously, they each independently call /auth/refresh. Tab A rotates the token; Tab B presents the now-invalidated old token → reuse detection → entire token family revoked → both tabs logged out. The singleton only serialises concurrent fetches within the same tab. The same symptom appears at three different scopes — each needs a different fix.
-- Singleton refresh gate --
let refreshPromise = null;

async function getAccessToken() {
  if (!isExpired(accessToken)) return accessToken;
  if (!refreshPromise) {
    refreshPromise = callRefreshEndpoint()
      .then(t => { accessToken = t; refreshPromise = null; return t; });
  }
  return refreshPromise;
}

Three tiers of the refresh race — right fix for each

Scenario 1 — Same tab, multiple concurrent fetches
Step Fetch 1 Fetch 2 Fetch 3
1 token expired → getAccessToken() token expired → getAccessToken() token expired → getAccessToken()
2 refreshPromise = null → start /auth/refresh refreshPromise exists → await it refreshPromise exists → await it
3 new token → resolves same promise resolves ✓ same promise resolves ✓
✓ Fix: singleton promise — one module-level refreshPromise shared by all callers in the tab. Only one network call fires.
Scenario 2 — Two tabs, same browser

Without fix (singleton alone is not enough):

StepTab ATab B
1 token expired → call /auth/refresh with R1 token expired → call /auth/refresh with R1
2 gets R2  ·  R1 invalidated on server presents R1 (already rotated!) → REUSE DETECTED
3 server revokes entire token family both tabs get 401 → user logged out ✗

With Web Locks + BroadcastChannel:

StepTab ATab B
1 token expired → request lock 'refresh' token expired → request lock 'refresh'
2 lock acquired ✓ → call /auth/refresh (R1) waiting for lock…
3 gets R2 → token = R2 waiting for lock…
4 channel.postMessage(R2) → ← onmessage: token = R2 ✓ (memory updated)
5 release lock lock acquired → isExpired(token)? NO → skip ✓
✓ Fix: Web Locks (turn-taking) + BroadcastChannel (shared state). Locks alone don't work — Tab B's in-memory token is still stale until the broadcast updates it.
Scenario 3 — Multiple browsers / devices

Not a race problem. Each login creates a separate row in the session DB. Rotating one row doesn't touch the others.

user_idrefresh_tokendevicestatus
123R-a1b2…Chrome / Macactive — rotates independently
123R-c3d4…Firefox / Macactive — unaffected by Chrome's rotation
123R-e5f6…Safari / iPhoneactive — unaffected

"Log out all devices" = DELETE FROM sessions WHERE user_id = 123. Each device gets a 401 on its next refresh attempt.

✓ No client-side fix needed. One refresh token per session row — the server already handles multiple devices natively.
One-liner

Singleton = intra-tab only. Cross-tab needs Web Locks + BroadcastChannel. Cross-device is a server problem — solved by one refresh token row per session.

5Cookies deep: SameSite, Secure, HttpOnly

Three cookie attributes together form the modern auth cookie pattern:

HttpOnly

XSS defense

Browser never exposes this cookie to JavaScript. document.cookie won't see it. XSS scripts can't steal it. Essential for any auth cookie.

Secure

MITM defense

Cookie only sent over HTTPS connections. Prevents plaintext exposure on mixed or HTTP pages. Required alongside SameSite=None.

SameSite

CSRF defense

Strict: only same-site requests. Lax: same-site + top-level navigation GETs (default modern). None: always sent (must be Secure).

SameSite: which to pick?

ValueSent onCSRF riskBreaks
Strict Same-site only None SSO redirect flows, email links — cookie missing on arrival, user appears logged out
Lax (default) Same-site + top-level GET navigations Low (GET is idempotent) Some embedded widgets. Safe for most web apps.
None; Secure All requests (cross-site too) Full CSRF exposure Needs CSRF token or double-submit cookie pattern
Trap — SameSite=Strict breaks cross-site flows that depend on an existing session: The email verification example is misleading — a well-designed verification token is self-contained: the server looks up token=abc123 in the DB, finds the user, marks them verified, and creates a new session. No pre-existing cookie needed. It works fine from a different device or browser where the user has never logged in.

The flows that actually break under Strict:

SameSite=Lax fixes all of these because it allows the cookie on cross-site top-level GET navigations — which is exactly what an OAuth callback or a "resume" link is.

FlowStrict safe?Why
Email verification link Yes Token is self-contained — server looks up the token, no session required
OAuth / SSO return redirect No Auth state in cookie must survive the cross-site hop back to your app
Cart / session resume from email No Server expects an existing session to identify the user
Password reset link Usually Good designs are token-only — no pre-existing session required

Cross-subdomain cookies

To share a cookie across www.example.com and api.example.com: set Domain=.example.com (leading dot = include all subdomains). Be aware: any compromised subdomain can now read and set that cookie.

Set-Cookie: session=<token>; HttpOnly; Secure; SameSite=Lax; Domain=.example.com; Path=/; Max-Age=2592000

6The recommended pattern: access + refresh split

Putting it all together, the modern standard for SPAs with a backend:

-- Access token: short-lived JWT in memory --
Authorization: Bearer eyJhbGciOiJSUzI1NiJ9...
Expiry: 15 minutes. Stored in a JS module variable. Never in localStorage.

-- Refresh token: opaque, long-lived, in HttpOnly cookie --
Set-Cookie: refresh=<opaque-token>; HttpOnly; Secure; SameSite=Lax; Path=/auth/refresh
Path=/auth/refresh means the cookie is ONLY sent to that one endpoint. Nothing else sees it.

-- On 401: refresh → new access token, transparent to the UI --
Trap — refresh cookie Path scope: Setting Path=/auth/refresh on the refresh cookie is a hardening move: the cookie is only auto-sent to that exact endpoint, not to every API call. This limits the exposure window if another endpoint has a CSRF vulnerability. Many teams skip this and pay later.

7CSRF is not solved by CORS (L17 tie-in)

A reminder from L17: CORS is browser-enforced on reading responses, but it does not prevent simple cross-origin requests from being sent. A malicious page can still trigger a cookie-carrying <form action="https://example.com/checkout" method="POST"> — no CORS preflight for a form POST with application/x-www-form-urlencoded.

SameSite=Lax/Strict is now the primary CSRF defense for modern browsers. For cases requiring SameSite=None (third-party embeds, cross-site widgets):

8platform-specific angle

Full loop

Concept: auth storage is a triangle — XSS-resistant (HttpOnly) vs CSRF-resistant (not a cookie) vs JS-accessible (needed for Bearer headers); pick two. Trade-off: HttpOnly cookies are the XSS-safe choice but introduce CSRF surface (mitigated by SameSite=Lax on modern browsers), while localStorage is convenient but a single XSS anywhere = long-lived token theft. Anchor: "We migrated from localStorage JWTs to memory + HttpOnly-refresh after a third-party analytics script was caught exfiltrating tokens from localStorage — the HttpOnly refresh meant the blast radius was one page session, not persistent access." Impact: the 15-min access-token window directly caps attacker dwell time, and refresh rotation + reuse detection gives revocation semantics without a blocklist DB hit on every request. Invite: "I'd reconsider the full in-memory approach for an offline-first PWA — there you'd need IndexedDB for the refresh token and would trade some XSS surface for the offline capability."

9Check yourself — scenario quiz

0 / 7

An attacker finds an XSS vulnerability in your app. Your access token is stored in localStorage. What can the attacker do that they couldn't if it were in memory?

Your team uses refresh token rotation. Two browser tabs both detect an expired access token and call /auth/refresh at the same time. What happens?

You set SameSite=Strict on your session cookie. A user receives a marketing email and clicks a link to their booking page on example.com. What happens?

Why can't you use Access-Control-Allow-Origin: * on your /auth/refresh endpoint?

Which cookie attribute limits the refresh token cookie to only being sent to the refresh endpoint, not to every API request?

A user has your SPA open in Tab A with an access token loaded into memory. They open a new Tab B to the same app. What does Tab B have?

A security audit flags your app for storing the JWT access token in localStorage "for interceptor convenience." What is the actual risk, and what is the right fix?

Verbal drill — out loud before next session

Whiteboard prompt: "A user logs into example.com from their phone, then opens three tabs. Fifteen minutes later, all three tabs try to make authenticated API calls simultaneously. Walk me through your auth architecture end-to-end: how tokens are issued, where they're stored, what happens when they expire, and how you prevent being logged out from a multi-tab race."

Hit: JWT in memory + HttpOnly refresh cookie, singleton refresh gate, rotation + reuse detection, SameSite=Lax, Path=/auth/refresh scope, 15-min access window.

Good follow-up topics:

Show me the singleton refresh gate in full PKCE and OAuth for SPAs Token family revocation in code Cookies vs tokens for mobile apps How does server-side session (express-session) compare? IndexedDB for PWA offline auth CSRF token double-submit pattern Silent refresh bootstrap on page load What if the refresh endpoint itself is compromised?