Cross-Site Scripting (XSS) - Contextual Encoding, Strict CSP Nonces & Trusted Types
XSS is attacker JavaScript running in your origin. Reflected, stored, and DOM XSS are three delivery routes. Contextual encoding is the fix. A strict nonce CSP and Trusted Types are the layer that still blocks the script when someone uses an escape hatch.
- 1Gist
- 2Maps
- 3Q&A
- 4Sandbox
Voice readout needs Web Speech Synthesis in this browser.
Question ladder
L1
Difference between stored, reflected, and DOM XSS?
Answer
Stored persists in your data and hits every viewer; reflected bounces off one request; DOM-based happens entirely in client JS when a source like `location.hash` flows into a sink like `innerHTML`.
L2
Why isn't escaping `<` and `>` enough?
Answer
Context matters. Inside an attribute, a quote breaks out; inside a URL attribute, `javascript:` needs no angle brackets; inside a script block, `</script>` ends the block.
L3
How does a nonce-based CSP stop XSS if the injection still happens?
Answer
The browser only runs scripts carrying the per-response nonce. The attacker can't predict it, and inline handlers are blocked without `'unsafe-inline'`.
L4
Does React prevent XSS?
Answer
It escapes text interpolation. It doesn't protect `dangerouslySetInnerHTML`, `href` or `src` with `javascript:` URLs, or server-rendered HTML outside React.
L5
Where should you store access tokens in a SPA?
Answer
Prefer an `HttpOnly`, `Secure`, `SameSite` cookie via a backend-for-frontend, so XSS can't read the token directly. In-memory storage is next best; `localStorage` is the most exposed.
L6
What are Trusted Types?
Answer
A browser feature (enabled with `require-trusted-types-for 'script'`) that makes `innerHTML` and similar sinks reject plain strings, so only values produced by an approved policy (often a sanitizer) can reach them.
L7
A comment feature needs bold, links, and lists. How do you build it safely?
Answer
Store raw input, render through a well-maintained sanitizer with a minimal allowlist of tags and attributes, force `rel="noopener noreferrer"` and http(s)-only links, and keep a strict CSP as backup. Markdown rendered to sanitized HTML is a common choice.
Failure modes
Blocklist sanitizer
svg onload and mixed-case tags never match the regex you wrote.
HttpOnly as the XSS fix
The script cannot read the cookie. It can still call your API as the victim.
Tokens in localStorage
Any XSS reads them and sends them home. The attacker does not need the page to stay open.
CSP with unsafe-inline
Injected event handlers run. The policy looks present and does not stop XSS.
Misconceptions
React escapes everything.
Text interpolation is escaped. dangerouslySetInnerHTML, javascript URLs, and HTML built outside React are not.
Escaping angle brackets is enough.
Attributes break on quotes. URL attributes break on schemes. Script blocks break on a closing script tag.
CSP replaces encoding.
CSP is the second layer. A missing nonce policy plus a raw sink is still XSS.
Interviewer traps
Defining XSS as alert(1) in a query string.
Say stored, reflected, and DOM, then the context, then the CSP decision.
Putting the access token in localStorage because the SPA has no server.
Prefer an HttpOnly cookie set by a backend-for-frontend. In memory is the next choice.
Design scenario
Same prompt for every reader.
Requirements
Comments render as formatted text. Search reflects the query as text. Injected script does not run even if a sanitizer regresses.
Traffic / scale
Comments are read far more often than they are written. Search is on every page view.
Latency
Sanitizing one comment is a local CPU cost, not a network hop.
Consistency
The stored comment is raw source. Every viewer gets the same sanitized HTML.
Availability
A CSP violation report must not block the page. Enforcement still blocks the script.
Failure assumptions
- A comment contains an event handler.
- Search echoes the query into HTML.
Constraints
- Links are http or https only.
- The session cookie is HttpOnly.
Prompt
A comment box needs bold, links, and lists. The same origin also has a search box that reflects the query, and a SPA that calls the API.
API
What is stored for a comment, and what does the search response contain?
Data
Which allowlist of tags and attributes survives sanitizing?
Architecture
Where is the nonce generated, and which header carries the CSP?
Text that stays text
Prefer
Encode for the context, then a nonce CSP
The template writes text. The browser refuses scripts that do not carry this response's nonce.
- HTML, attributes, URLs, and script data each have a rule.
- Rich text goes through a sanitizer with a short allowlist.
- Trusted Types make innerHTML a typed API.
Alternative
Strip script tags
The payload never has to look like a script tag to run.
- svg onload, mixed case, and nested tags walk around the regex.
- A javascript URL needs no angle brackets.
- unsafe-inline in CSP is almost no policy.
From a source to a sink
Delivery route first, then the context, then the browser policy that backs you up.
- 1
Name the route
Reflected in this response, stored for every viewer, or DOM-only in the client. - 2
Name the context
HTML body, attribute, URL, JavaScript string, CSS, or rich HTML. - 3
Encode or sanitize
Entity-encode text. Allowlist URL schemes. Run rich HTML through a sanitizer. - 4
Nonce the scripts
script-src nonce plus strict-dynamic. No unsafe-inline. Report-Only before enforce. - 5
Type the DOM sinks
Trusted Types reject raw strings at innerHTML and similar sinks.
Overview
Cross-site scripting (XSS) means an attacker gets JavaScript to run in your origin, inside another user's browser. That script can do anything the user can: read the page, call your APIs with their cookies, change their email, or exfiltrate tokens stored in localStorage. There are three delivery routes (reflected, stored, DOM-based) and one core fix: contextual output encoding, which modern frameworks do for you by default, plus a strict Content Security Policy and Trusted Types as the second layer for when someone uses an escape hatch.
Where a browser stores an access token, and how PKCE keeps the code off the attacker, is OAuth 2.1 and OIDC. The TLS session that carries the CSP header is HTTP versions and TLS.
Three kinds of XSS
| Type | Where the payload lives | Example | Who's hit | Main fix |
|---|---|---|---|---|
| Reflected | In the request (query string, form) and echoed back | /search?q=<script>... in a phishing link | Users who click the link | Encode output in the server template |
| Stored | Persisted in your DB (comments, profile, filenames) | Comment body with <img onerror> | Everyone who views it, including admins | Encode output; sanitize rich text with an HTML sanitizer |
| DOM-based | Never touches the server; client JS reads a source and writes a sink | el.innerHTML = location.hash | Users who open a crafted URL | Use textContent, safe DOM APIs, Trusted Types |
Decisions
- 1
1. Source: URL, form, stored comment, postMessage
- next2. Server template or client JS builds output
- 2
2. Server template or client JS builds output
- next3. Encoded for its exact context?
- ?
3. Encoded for its exact context?
- yes4a. Browser renders text, no script
- no4b. Browser parses markup or JS
- 4
4a. Browser renders text, no script
- 5
4b. Browser parses markup or JS
- next5. Strict CSP nonce present?
- ?
5. Strict CSP nonce present?
- yes6a. Injected script blocked, violation reported
- no6b. Script runs as the victim
- 7
6a. Injected script blocked, violation reported
- 8
6b. Script runs as the victim
Lesson map
Cross-Site Scripting (XSS) - Contextual Encoding, Strict CSP Nonces & Trusted Types
XSS is attacker JavaScript running in your origin. Reflected, stored, and DOM XSS are three delivery routes. Contextual encoding is the fix. A strict nonce CSP and Trusted Types are the layer that still blocks the script when someone uses an escape hatch.
Architecture. Architecture
Select a node to see why it exists, or an edge to see the protocol, direction, effect, and consequence.
Mermaid export
flowchart TB a["1. Source: URL, form, stored comment, postMessage"] b["2. Server template or client JS builds output"] c["3. Encoded for its exact context?"] d["4a. Browser renders text, no script"] a -->|1. Source: URL, form, stored comment, postMessage to 2. Server template or client JS builds output| b b -->|2. Server template or client JS builds output to 3. Encoded for its exact context?| c c -->|yes| d
Encoding depends on the context
HTML body, HTML attributes, JavaScript strings, and URLs each have different special characters. An attribute escaped for HTML but placed in an href still runs javascript: URLs.
# XSS: the right encoding depends on WHERE the value lands (the context).
import html, json
from urllib.parse import urlparse
def body(v): # HTML text node: entity-encode
return f"<p>Hello {html.escape(v)}</p>"
def attr(v): # quoted attribute: escape quotes too (html.escape does by default)
return f'<input value="{html.escape(v, quote=True)}">'
def js_data(v): # data for a script: JSON-encode, and neutralize '</' and '<!--'
safe = json.dumps(v).replace("<", "\\u003c")
return f"<script>const user = {safe};</script>"
def href(v): # URL attribute: encoding is NOT enough, the scheme must be allowed
scheme = urlparse(v.strip()).scheme.lower()
if scheme not in ("http", "https", "mailto", ""):
v = "about:blank"
return f'<a href="{html.escape(v)}">profile</a>'
print(body('<img src=x onerror=alert(1)>'))
print(attr('" autofocus onfocus="alert(1)'))
print(js_data('</script><script>alert(1)</script>'))
print(href("javascript:alert(document.cookie)"))
print(href(" JaVaScRiPt:alert(1)"))
print(href("https://example.com/u/42"))Output:
<p>Hello <img src=x onerror=alert(1)></p>
<input value="" autofocus onfocus="alert(1)">
<script>const user = "\u003c/script>\u003cscript>alert(1)\u003c/script>";</script>
<a href="about:blank">profile</a>
<a href="about:blank">profile</a>
<a href="https://example.com/u/42">profile</a>| Context | Example | Correct handling | Wrong handling that still looks fine |
|---|---|---|---|
| HTML body | <p>{name}</p> | HTML entity encode (<) | Stripping only <script> |
| Quoted attribute | value="{x}" | Entity encode including quotes | Unquoted attributes (value={x}) |
| URL attribute | href="{url}" | Allow only http, https, mailto schemes, then encode | Encoding without checking scheme |
| JS data | <script>var u = {x}</script> | JSON-encode and escape <; better, put data in a data- attribute or JSON script tag | Wrapping in quotes by hand |
| CSS | style="color: {c}" | Allowlist values | Anything else |
| Rich HTML (comments with formatting) | User-authored HTML | Sanitizer such as DOMPurify (client) or bleach / nh3 (server) | Regex-based tag stripping |
Second layer: strict CSP with nonces
A nonce-based CSP tells the browser to run only <script> tags that carry a random value generated for this response. An attacker who injects markup doesn't know the nonce, and inline event handlers (onerror=) are blocked entirely.
// CSP with a per-response nonce: only <script> tags carrying today's nonce run,
// so an injected <script> or inline onerror= handler is blocked by the browser.
// (Node's crypto via require because the sandbox has no @types/node.)
declare function require(m: string): any;
const { randomBytes } = require("crypto");
function buildCsp(nonce: string): string {
return [
"default-src 'self'",
`script-src 'nonce-${nonce}' 'strict-dynamic'`, // trust propagates to scripts it loads
"object-src 'none'",
"base-uri 'none'", // stop <base href> hijacking relative script URLs
"frame-ancestors 'none'",
"require-trusted-types-for 'script'", // DOM sinks need TrustedHTML
].join("; ");
}
// Tiny model of the browser's decision for inline script elements.
function allowedToRun(tag: { nonce?: string; inlineHandler?: boolean }, nonce: string): boolean {
if (tag.inlineHandler) return false; // no 'unsafe-inline'/'unsafe-hashes' => handlers blocked
return tag.nonce === nonce;
}
const nonce: string = randomBytes(16).toString("base64");
console.log("CSP:", buildCsp("<nonce>"));
const cases = [
{ name: "app bundle <script nonce=...>", tag: { nonce } },
{ name: "injected <script>alert(1)</script>", tag: {} },
{ name: "injected <img onerror=...>", tag: { inlineHandler: true } },
{ name: "attacker guesses old nonce", tag: { nonce: "c3RhbGUtbm9uY2U=" } },
];
for (const c of cases) console.log(`${c.name.padEnd(38)} -> ${allowedToRun(c.tag, nonce) ? "runs" : "BLOCKED"}`);Output:
CSP: default-src 'self'; script-src 'nonce-<nonce>' 'strict-dynamic'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; require-trusted-types-for 'script'
app bundle <script nonce=...> -> runs
injected <script>alert(1)</script> -> BLOCKED
injected <img onerror=...> -> BLOCKED
attacker guesses old nonce -> BLOCKEDRoll CSP out with Content-Security-Policy-Report-Only first, collect violation reports, fix legitimate inline scripts, then enforce. Allowlist-based CSPs (script-src 'self' cdn.example.com) are weaker: Google's research found most of them bypassable through JSONP endpoints or script gadgets on the allowlisted hosts.
What happens if you choose differently
- Blocklist sanitizing (
replace(/<script>/g, "")): bypassed by<svg onload>,<ScRiPt>, nested tags, and encodings. HttpOnlycookies only: stops cookie theft, not XSS. The script can still make authenticated requests from the victim's browser.- Tokens in
localStorage: any XSS reads them directly and sends them off; with anHttpOnlycookie the attacker has to act while the victim's page is open. - CSP with
'unsafe-inline': almost no XSS protection; it's equivalent to having no script policy. - Trusting the framework blindly:
dangerouslySetInnerHTML,v-html,[innerHTML],href={userUrl}, and server-side|safe/rawfilters are the usual culprits in React, Vue, Angular, Jinja, and Rails apps.
Pros and cons of XSS defenses
| Defense | Pros | Cons |
|---|---|---|
| Framework auto-escaping | On by default, covers most text | Escape hatches; doesn't validate URL schemes |
| HTML sanitizer (DOMPurify) | Lets users write rich text safely | Must be kept updated; config mistakes |
| Strict nonce CSP | Blocks most injected scripts even after a bug | Requires nonce plumbing; third-party scripts need strict-dynamic |
| Trusted Types | Turns DOM sinks into typed APIs; finds DOM XSS at dev time | Chromium-first; migration effort for legacy code |
HttpOnly + SameSite cookies | Limits session theft | Doesn't stop on-page actions |
Find every dangerouslySetInnerHTML, v-html, bypassSecurityTrust, and raw or safe filter. For each one, write the context it writes into and whether a nonce CSP would still block a script tag.
Interview Q&A
Difference between stored, reflected, and DOM XSS?
Answer
Stored persists in your data and hits every viewer; reflected bounces off one request; DOM-based happens entirely in client JS when a source like location.hash flows into a sink like innerHTML.
Why isn't escaping `<` and `>` enough?
Answer
Context matters. Inside an attribute, a quote breaks out; inside a URL attribute, javascript: needs no angle brackets; inside a script block, </script> ends the block.
How does a nonce-based CSP stop XSS if the injection still happens?
Answer
The browser only runs scripts carrying the per-response nonce. The attacker can't predict it, and inline handlers are blocked without 'unsafe-inline'.
Does React prevent XSS?
Answer
It escapes text interpolation. It doesn't protect dangerouslySetInnerHTML, href or src with javascript: URLs, or server-rendered HTML outside React.
Where should you store access tokens in a SPA?
Answer
Prefer an HttpOnly, Secure, SameSite cookie via a backend-for-frontend, so XSS can't read the token directly. In-memory storage is next best; localStorage is the most exposed.
What are Trusted Types?
Answer
A browser feature (enabled with require-trusted-types-for 'script') that makes innerHTML and similar sinks reject plain strings, so only values produced by an approved policy (often a sanitizer) can reach them.
A comment feature needs bold, links, and lists. How do you build it safely?
Answer
Store raw input, render through a well-maintained sanitizer with a minimal allowlist of tags and attributes, force rel="noopener noreferrer" and http(s)-only links, and keep a strict CSP as backup. Markdown rendered to sanitized HTML is a common choice.