JWT vs opaque tokens, refresh rotation, and the XSS-vs-CSRF storage trade-off.
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).
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:
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:
⚠ 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.
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.
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.
| 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.
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).
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.
| Property | JWT | Opaque |
|---|---|---|
| Verification cost | Crypto (fast, no I/O) | DB/Redis lookup |
| Revocation | Can't revoke before expiry* | Instant (delete row) |
| Horizontal scale | Stateless — any server verifies | Shared store required |
| Payload visible | Yes (base64 decoded, not encrypted) | No |
| Token size | 200–400 bytes typical | 16–32 bytes |
| Typical use | Short-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.
A JWT is three Base64URL-encoded strings joined by dots: header.payload.signature
{
"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).
{
"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.
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).
{"alg":"RS256","typ":"JWT"} → Base64URL encode → Hsub, roles, exp, iat) → Base64URL encode → PS = Base64URL( RS256( H + "." + P, privateKey ) )"." → extract H, P, Sexpected = Base64URL( RS256( H + "." + P, publicKey ) )expected === S. If mismatch → reject (token was tampered with or signed with a different key).exp (not expired), iss (correct issuer), aud (correct audience). Any failure → reject.sub, roles) and serve the request. Zero DB calls.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.
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.
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.
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.
Refresh tokens are long-lived and therefore the highest-value theft target. Rotation limits the damage window if one is stolen.
Secure cookie.Authorization: Bearer <jwt> on API calls.POST /auth/refresh — cookie is auto-sent.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;
}
| 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 ✓ |
refreshPromise shared by all callers in the tab. Only one network call fires.Without fix (singleton alone is not enough):
| Step | Tab A | Tab 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:
| Step | Tab A | Tab 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 ✓ |
Not a race problem. Each login creates a separate row in the session DB. Rotating one row doesn't touch the others.
| user_id | refresh_token | device | status |
|---|---|---|---|
| 123 | R-a1b2… | Chrome / Mac | active — rotates independently |
| 123 | R-c3d4… | Firefox / Mac | active — unaffected by Chrome's rotation |
| 123 | R-e5f6… | Safari / iPhone | active — unaffected |
"Log out all devices" = DELETE FROM sessions WHERE user_id = 123. Each device gets a 401 on its next refresh attempt.
Singleton = intra-tab only. Cross-tab needs Web Locks + BroadcastChannel. Cross-device is a server problem — solved by one refresh token row per session.
Three cookie attributes together form the modern auth cookie pattern:
HttpOnlyBrowser never exposes this cookie to JavaScript. document.cookie won't see it. XSS scripts can't steal it. Essential for any auth cookie.
SecureCookie only sent over HTTPS connections. Prevents plaintext exposure on mixed or HTTP pages. Required alongside SameSite=None.
SameSiteStrict: only same-site requests. Lax: same-site + top-level navigation GETs (default modern). None: always sent (must be Secure).
| Value | Sent on | CSRF risk | Breaks |
|---|---|---|---|
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 |
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:
example.com → accounts.google.com
(user authenticates) → redirects back to example.com/auth/callback.
That return hop is a cross-site top-level GET. Strict drops the cookie — the half-completed
login state is lost and the user lands unauthenticated even though Google auth succeeded.
example.com/cart/resume. The server logic requires knowing who the user is
to show their saved cart — it expects an existing session. Strict drops the cookie on that
cross-site navigation → empty cart or login wall.
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.
| Flow | Strict 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 |
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
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 --
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.
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):
X-CSRF-Token). Server validates header matches session. XSS risk: JS can read the non-HttpOnly cookie — acceptable trade-off for CSRF protection.X-Requested-With: XMLHttpRequest) triggers a CORS preflight for cross-origin requests, which a simple form can't forge. Weak defense; use as belt-and-suspenders only.SameSite=Lax on session cookies ensures users coming back from a background-killed tab still get their session cookie on the returning navigation.Domain=.example.com allows the same session cookie to work across partner portals and subdomains. Trade-off: subdomain compromise = cookie compromise. Pair with strict CSP (L19).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."
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?
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: