Lesson 2 of 11 · 8 min read
Cross-Site Scripting and Output Encoding
In this lesson
TL;DR: XSS is untrusted content executing as script in your site's pages, with your origin's privileges and the victim's session. Stored XSS is saved and served to others; reflected XSS bounces off a request; DOM XSS happens entirely in client-side code. Prevent it by encoding output for its context (HTML text, attribute, URL, JavaScript), which modern frameworks do by default unless you bypass them, by sanitising with a vetted library when you genuinely must render HTML, and by a strict CSP that blocks inline and unexpected scripts as a backstop. Treat model output as untrusted input to the renderer.
Before you read
Pick an answer before you read further. The lesson below explains it, and guessing first, right or wrong, helps the explanation stay with you.
1A React app renders user comments with {comment}. A new feature renders the AI assistant's Markdown answers by converting them to HTML and using dangerouslySetInnerHTML. Which one is the XSS risk, and why?
Why it is serious
Script that runs in your page runs as your site. It can read anything the page can, call your APIs with the victim's cookies, change what the user sees, capture what they type, and send it all elsewhere. HttpOnly cookies stop script from reading the session cookie, but injected script can still act as the user while the page is open.
Three kinds
| Kind | How the payload arrives | Example |
|---|---|---|
| Stored | Saved by the server and served to other users | A comment, profile field or chat message containing markup |
| Reflected | Echoed from the request into the response | A search page that prints the query unencoded |
| DOM-based | Client-side code moves data from a source to a dangerous sink | location.hash written into innerHTML |
DOM XSS: sources and sinks
Stored and reflected XSS pass through the server, so server-side encoding, logs and web application firewalls at least see them. DOM XSS may never reach the server at all: a value read from location.hash stays in the browser. Reviewing for it means tracing sources (where attacker-influenced data enters client code) to sinks (APIs that turn strings into markup, script or navigation).
| Sources | Sinks that execute or render | Safe alternative |
|---|---|---|
location.hash, location.search, document.referrer | innerHTML, outerHTML, insertAdjacentHTML, document.write | textContent, DOM construction with createElement |
postMessage data from another window | eval, new Function, setTimeout with a string | Parse data as JSON, never evaluate it |
Values from localStorage or IndexedDB that another page wrote | element.src or href set to a javascript: URL | Validate the scheme before assigning URLs |
| API responses containing user content, including model output | Framework escape hatches: dangerouslySetInnerHTML, v-html, Angular's bypassSecurityTrust* | Render as text, or sanitise with an allowlist first |
postMessage deserves its own sentence because it is a cross-origin channel into your page: a listener that does not check event.origin accepts messages from any window that holds a reference to yours, including a page that opened yours in a popup.
Trusted Types turns sink discipline into a browser-enforced rule. With the CSP directive require-trusted-types-for 'script', sinks like innerHTML reject plain strings and accept only objects produced by a named policy you wrote, typically one that runs DOMPurify. Every place that writes HTML must then go through a small number of reviewed policies. Chromium-based browsers have supported it since Chrome 83 in 2020, and other engines have added it since; check current support for your users before relying on enforcement, and roll it out in report-only mode first.
Contextual output encoding
Encoding converts characters that have meaning in a context into inert representations. The correct encoding depends on where the data lands:
| Context | Example | Encoding |
|---|---|---|
| HTML text | <p>DATA</p> | HTML-entity encode <, >, &, quotes |
| HTML attribute | <input value="DATA"> | Attribute encoding, always quote attributes |
| URL parameter | href="/search?q=DATA" | Percent-encode the value, and validate the scheme of whole URLs (no javascript:) |
| JavaScript | <script>var x = DATA</script> | Avoid; pass data via JSON in a data attribute or an API call |
Modern frameworks (React, Vue, Angular, server templates with auto-escaping) encode HTML text and attributes by default. Most XSS in modern apps comes from bypassing that default: dangerouslySetInnerHTML, v-html, innerHTML, |safe filters, or building HTML strings by hand.
from html import escape
model_output = 'Here is your summary <img src=x onerror="alert(1)">'
print(f"<p>{escape(model_output)}</p>")
# <p>Here is your summary <img src=x onerror="alert(1)"></p>
When you must render HTML: sanitise
Rich text editors and Markdown renderers produce HTML on purpose. Encoding would destroy the formatting, so use a sanitiser that parses the HTML and keeps only an allowlist of safe elements and attributes. DOMPurify is the common choice in browsers. Disable raw HTML in Markdown renderers unless you need it, and restrict link schemes to http, https and mailto.
Mutation XSS and sanitiser configuration
A sanitiser parses HTML into a tree, removes what is not allowed and serialises the result. If the browser later parses that string differently, for example because it is inserted into a different context, or modified after sanitising, or sanitised by a library whose parser disagrees with the browser's, markup that looked inert can become active. This class is called mutation XSS. The practical rules: sanitise as late as possible, immediately before insertion, in the same context it will be inserted into; do not edit the sanitised string afterwards; keep the sanitiser current; and keep the allowlist small (no style, no <svg> or <math> unless needed, no event-handler attributes, link schemes limited to http, https and mailto).
Content Security Policy as the second layer
A CSP header tells the browser which scripts and resources a page may load. A strict policy that allows scripts only with a per-response nonce (and uses strict-dynamic), and forbids inline event handlers, means most injected markup cannot execute even if encoding fails somewhere. CSP can also restrict where images load from, which closes the markdown image exfiltration channel. For DOM XSS, the Trusted Types browser API forces dangerous sinks to accept only values produced by vetted policies. CSP is defence in depth, not a substitute for encoding.
Why most CSPs did not work
CSP has existed since 2012, yet for years most deployed policies gave no protection. Google researchers measured why in "CSP Is Dead, Long Live CSP!" (Weichselbaum, Spagnuolo, Lekies and Janc, ACM CCS 2016). Across 1,680,867 hosts and 26,011 unique policies:
- 94.68% of policies that tried to limit script execution were ineffective, and 99.34% of hosts with a CSP gained no XSS protection from it.
- The main cause was host allowlists. Allowing a CDN or a widely used domain allows every script on it, including old library versions with known bypasses and JSONP endpoints that return attacker-chosen JavaScript. 14 of the 15 domains most often allowlisted hosted such endpoints, which made 75.81% of distinct policies bypassable.
- The paper proposed
strict-dynamic, the basis of today's recommended "strict CSP".
A strict CSP looks like this:
Content-Security-Policy:
script-src 'nonce-R4nd0m' 'strict-dynamic' https: 'unsafe-inline';
object-src 'none';
base-uri 'none';
frame-ancestors 'self';
report-to csp-endpoint
Host allowlist: script-src self cdn.example
Injected script tag
Points at an old library on the CDN
host is allowed
Library runs attacker code
Outcome, unsafe: Policy allows the bypass
Everything on an allowed host is trusted.
Strict: nonce plus strict-dynamic
Injected script tag
Has no valid nonce
nonce differs per response
Browser refuses to run it
Outcome, safe: Injection does not execute
Only scripts the server tagged can run or load others.
Trusting hosts
The attacker does not need their own server; they reuse a script already on a host you trusted.
Each response carries a fresh, unguessable nonce, and only <script> tags with that nonce run. strict-dynamic lets those trusted scripts load further scripts, so tag managers and bundlers keep working without host lists. The https: and 'unsafe-inline' entries are fallbacks that browsers supporting nonces and strict-dynamic ignore. object-src 'none' removes plugin content, base-uri 'none' stops an injected <base> tag redirecting relative script URLs, and frame-ancestors replaces the older X-Frame-Options header for clickjacking protection. Deploy it first as Content-Security-Policy-Report-Only, collect violation reports for a few weeks, fix the inline handlers and javascript: URLs they reveal, then enforce.
XSS defeats CSRF defences
OWASP's CSRF cheat sheet puts it bluntly: XSS can defeat all CSRF mitigations. Script on your origin can read the CSRF token from the page and send same-origin requests that pass Origin, Fetch Metadata and SameSite checks. That is why XSS is fixed first, and why the same-origin policy, CORS and CSRF lesson assumes your origin's script is your own.
Chat interfaces and model output
Web page
Contains hidden markup
Assistant
Summarises the page
Chat history
Shared conversation
Chat UI
Renders the answer
Other viewer
Markup travels through the model
The page steers the assistant into including markup in its answer. The answer is saved, and a shared conversation shows it to other users.
Model output is attacker-influenced whenever the model has read anything an attacker wrote. OWASP's Top 10 for LLM Applications calls the resulting class improper output handling. For chat UIs: render as text or safe Markdown, sanitise any HTML, restrict link and image sources, and treat shared conversations as stored content.
Worked example: a DOM XSS fix, end to end
A help widget on a product page runs:
// vulnerable: the fragment never reaches the server, so no WAF or log sees it
widget.innerHTML = decodeURIComponent(location.hash.slice(1));
The team also has a CSP whose script-src lists the company's own domain and a public CDN.
- Immediate fix. Replace the sink:
widget.textContent = decodeURIComponent(location.hash.slice(1));The value is now text, whatever it contains. - Find the others. Enable
require-trusted-types-for 'script'in report-only mode. Two weeks of reports list every remaining place that assigns strings to HTML sinks, including two inside a third-party chat widget. Route the legitimate ones through one DOMPurify-backed Trusted Types policy, then enforce. - Fix the CSP. The CDN allowlist meant any script hosted on that CDN, including an old templating library that evaluates expressions in HTML, would run; the policy gave no protection, which matches the 2016 measurements. Replace it with a nonce-based policy with
strict-dynamic, rolled out in report-only mode first. - Result. A future
innerHTMLmistake now throws under Trusted Types, and if one slips through, injected inline script without the nonce does not run.
Misconceptions interviewers listen for
- "CSP replaces output encoding." It is a second layer that catches what encoding misses.
- "HttpOnly cookies stop XSS." They stop script reading the cookie. Injected script still acts as the user.
- "Input validation prevents XSS." Encoding depends on the output context; the same input is safe in one place and dangerous in another.
- "React makes XSS impossible." Not with
dangerouslySetInnerHTML,javascript:URLs inhref, or server-side rendering that injects unescaped data. - "Sanitise once on input and you're done." Re-parsing or editing sanitised HTML is where mutation XSS comes from; sanitise at output.
What if · interview follow-ups
Hold your answer as the constraints change
You are reviewing a support web app that renders customer messages, uses a third-party chat widget and shows an AI assistant's answers as Markdown. The interviewer probes one rendering path at a time.
Start here
What if a customer's display name is inserted into an HTML attribute without quotes?
Which characters break out of an unquoted attribute?
A space or a greater-than sign ends the attribute, so a name containing a space followed by an event handler attribute adds script to the element. Always quote attributes and attribute-encode values; for URLs, also validate the scheme. Better still, let the template engine's auto-escaping produce the attribute instead of string concatenation.
Carry it forwardEncoding is per context; an attribute needs quoting plus attribute encoding, and URLs need scheme checks too.
Weak answerSaying HTML-entity encoding of angle brackets is enough for attributes.
Constraint changes
What if the app's CSP allowlists a popular public CDN in script-src?
What else does that CDN host?
Every script on the CDN becomes trusted, including old library versions and endpoints that can be made to execute attacker-chosen code, so injected markup can load one of them and run. Google's 2016 study found allowlist policies like this were bypassable in most cases. Move to a nonce-based policy with strict-dynamic, object-src none and base-uri none.
Carry it forwardHost allowlists trust everything on the host; nonces trust specific script tags.
Weak answerBelieving a short allowlist of reputable domains makes the CSP strict.
Constraint changes
What if a widget writes location.hash into innerHTML?
Does that value ever reach the server?
It is DOM XSS: the fragment is not sent to the server, so server-side encoding, logs and the WAF see nothing. Replace the sink with textContent, then enable Trusted Types in report-only mode to find every other string-to-HTML sink, route legitimate ones through a reviewed sanitising policy and enforce. A strict CSP limits damage from any sink that remains.
Carry it forwardTrace sources to sinks in client code; Trusted Types makes dangerous sinks a reviewed exception.
Weak answerProposing a server-side filter for a value that never reaches the server.
Constraint changes
What if XSS exists but every form has a CSRF token?
Can same-origin script read the token?
The token gives no protection: the injected script reads it from the page and sends valid same-origin requests. CSRF defences assume your origin's script is trusted. Fix the XSS, use a strict CSP to limit what injected script can load and where it can send data, and require re-authentication for the most sensitive actions.
Carry it forwardXSS turns the attacker into your own page; every same-origin check then passes.
Weak answerCounting CSRF tokens as an XSS mitigation.
Constraint changes
What if the AI assistant's Markdown answers can contain links and images?
Who decides the URLs, and when are images fetched?
Anyone who can influence the model's input can influence those URLs. Disable raw HTML in the Markdown renderer, sanitise the output, allow only http and https links, and restrict image sources to your own hosts in CSP img-src, because an image URL with data in its query string leaks it as soon as the page renders, without a click. Show link destinations before navigation.
Carry it forwardModel output is untrusted input to the renderer, and resource URLs are an exfiltration channel.
Weak answerEncoding the text but allowing arbitrary image URLs.
Now defend it
As the defender, how would you know about XSS attempts and regressions in production?
Collect CSP and Trusted Types violation reports and alert on new sources, block or flag commits that add string-to-HTML sinks or framework escape hatches, include stored-content and model-output rendering in security tests, and watch for unusual outbound requests from pages, such as image loads to unknown hosts, which indicate exfiltration attempts.
How this shows up in interviews
Expect "explain XSS and how you prevent it", often followed by "does CSP stop DOM XSS?" or a code snippet that uses innerHTML. Strong answers name the three kinds, explain contextual encoding and why frameworks are safe by default, identify the bypasses, and position CSP as a second layer. For AI roles, add the chat UI case without being asked.
Say it in 30 seconds: "XSS is untrusted content running as script in our origin, with the user's session. It's stored, reflected or DOM-based. I prevent it by encoding output for its context, which frameworks do by default unless someone uses innerHTML or dangerouslySetInnerHTML, sanitising with an allowlist when we must render HTML, and a strict nonce-based CSP as backup. Model output in a chat UI is untrusted input to the renderer, so the same rules apply."
Check yourself
Say or write your answer first, then reveal. Explaining it from memory, in your own words, is the skill an interviewer is checking.
1A link in an AI answer is rendered as <a href=MODEL_URL>. The URL is HTML-attribute encoded. What can still go wrong?
2Why does a strict CSP help even in an app that encodes output correctly everywhere today?
3Why did most host-allowlist CSPs fail to stop XSS, according to Google's 2016 study?
4What does Trusted Types change about a call like element.innerHTML = value?