XSS & safe DOM updates
Learn how XSS turns untrusted data into code, then prevent it with textContent, escaping, sanitizers, Trusted Types, and CSP.
- 01Think like a defenderName where untrusted data enters a page and which DOM sinks can execute it.
- 02Render data safelyChoose textContent, URL validation, escaping, or an allowlist sanitizer for the context.
- 03Add browser guardrailsExplain how Trusted Types and Content Security Policy reduce the damage when a mistake slips in.
What XSS means
Cross-site scripting, usually shortened to XSS, happens when untrusted data reaches a browser feature that treats it as code. A search query, comment, profile field, API value, URL hash, stored draft, or message event should stay data. If your code puts that value into an HTML, URL, or JavaScript sink without checking the context, the browser may create elements, run handlers, navigate to a script URL, or evaluate a string.
XSS is a data-to-code bug: input you did not fully control is interpreted as HTML, JavaScript, or another executable browser instruction.
This lesson teaches the attacker mindset only for defense. Ask, “If this string were chosen by someone hostile, where could it enter, which sink would parse it, and what would stop it?” Never test on real sites without permission. The safe demos here set flags inside a sandbox or print text so you can prove behavior without harm.
Why it matters: script running in a user’s page can read data the page can read, send actions as that user, change the DOM they trust, or steal tokens that were placed where JavaScript can reach them. The fix is the same principle throughout this module: never trust input; validate, encode, sanitize, and restrict what the browser is allowed to run.
Imagine a visitor writes “put this on the lobby sign.” If the clerk uses a label printer, the words appear as words. If the clerk gives the note to a contractor who treats it as building instructions, the visitor can change doors and alarms. Safe DOM updates choose the label printer unless you truly need a contractor and have checked the plans.
- In real life: Visitor writes a note at reception
- In JavaScript: User input from a form, URL, API, storage, or message
- In real life: A clerk copies the note onto a public sign
- In JavaScript: Your rendering code updates the DOM
- In real life: Plain label printer
- In JavaScript:
textContentprints data as text - In real life: Contractor who can change walls
- In JavaScript:
innerHTMLparses markup and can create active elements - In real life: Security desk checks badges
- In JavaScript: Sanitizers, Trusted Types, and CSP add guardrails
Where the analogy stops: Buildings have people who can judge intent. Browsers follow parsing rules exactly. A string that looks harmless in one context can become active in another context.
Sources, sinks, and safe text
SANDBOXED DEMOXSS analysis starts by drawing a line from a source to a sink. Sources are places untrusted data can enter: URL query parameters and hashes, form fields, API responses, postMessage data, localStorage, copied rich text, or anything saved by another user. Sinks are browser APIs that parse strings as HTML, URLs, or JavaScript.
// Sources: places untrusted data can enter your code.const fromUrl = new URLSearchParams(location.search).get("q");const fromHash = location.hash.slice(1);const fromStorage = localStorage.getItem("draft-comment");const fromMessage = event.data; // only after checking event.originconst fromApi = await response.json(); // Dangerous sinks: strings become code or markup.output.innerHTML = fromUrl;card.outerHTML = fromApi.html;panel.insertAdjacentHTML("beforeend", fromStorage);document.write(fromHash);link.href = fromUrl; // dangerous when the scheme is javascript:setTimeout(fromMessage, 0); // string form evaluates codeThe three classic categories name where the source-to-sink path lives. Reflected XSS bounces a value from the request into the response, such as a search term. Stored XSS saves the value first, such as a profile bio or comment that later readers load. DOM-based XSS happens in client-side JavaScript, often from location.hash, postMessage, storage, or API data. In every case the bug is still “untrusted data reached an unsafe sink.”
const payload = '<button onclick="window.xssRan = true">Run handler</button>';const host = document.querySelector("#comment-output"); window.xssRan = false;host.innerHTML = payload;host.querySelector("button")?.click();console.log("innerHTML handler ran:", window.xssRan === true); window.xssRan = false;host.textContent = payload;host.querySelector("button")?.click();console.log("textContent handler ran:", window.xssRan === true); window.scriptRan = false;host.innerHTML = "<script>window.scriptRan = true;<\/script>";console.log("script inserted with innerHTML ran:", window.scriptRan === true);Waiting for the sandbox to run.
The sandbox parses the button and .click() runs its harmless inline handler. Result: Waiting for the sandbox to run.
Chrome, like other modern browsers, does not execute <script> elements inserted through innerHTML. That fact does not make innerHTML safe. Event handler attributes such as onclick or onerror, dangerous URL schemes, SVG/MathML edges, and markup that changes the page can still matter. The demo uses a synchronous onclick and .click() so the proof is deterministic; a classic <img onerror> payload is asynchronous and less reliable for tests.
node.textContent = userInputnode.innerHTML = userInputnode.outerHTML = apiHtmlnode.insertAdjacentHTML('beforeend', html)link.href = userInputiframe.src = userInputeval(userInput)setTimeout(userInput, 0)
Sort each assignment or call by what kind of sink it is. If the sink parses or executes a string, it needs a guard before untrusted data reaches it.
Escape for the right context
STEP THROUGHIf you are rendering plain text into an HTML string, escape the characters that have meaning in that context. For an HTML body or a quoted HTML attribute, that means at least &, <, >, double quotes, and single quotes. Escape ampersands first so you do not double-interpret entity text.
Step through a real HTML escaper. It is small because it solves one context: text or a quoted HTML attribute, not URLs, JavaScript, or CSS.
script
"&": "&", "<": "<", ">": ">", '"': """, "'": "'",}; function escapeHtml(value) { return String(value).replace(/[&<>"']/g, (character) => htmlEscapes[character]);} const comment = 'Tom & <img src=x onerror="console.log(\'XSS ran\')">';console.log(escapeHtml(comment));const htmlEscapes = { "&": "&", "<": "<", ">": ">", '"': """, "'": "'",}; function escapeHtml(value) { return String(value).replace(/[&<>"']/g, (character) => htmlEscapes[character]);} const comment = 'Tom & <img src=x onerror="console.log(\'XSS ran\')">';console.log(escapeHtml(comment));Escaping is context-specific. HTML body text, quoted attributes, URLs, JavaScript string literals, and CSS values all have different rules. Do not use one helper everywhere and call the page safe. Prefer DOM APIs that avoid string contexts entirely: textContent for text, setAttribute for known-safe attributes, classList for classes, and event listeners for behavior.
const user = '<img src=x onerror="console.log(1)">'; // HTML body context: encode markup characters.message.innerHTML = escapeHtml(user); // Attribute context: quote the attribute and escape quotes.card.setAttribute("aria-label", user); // URL context: parse and allow only expected schemes.link.href = safeUrlAttribute(userUrl); // JavaScript and CSS contexts: do not build code or style text with user data.button.addEventListener("click", () => save(user));URLs need parsing, not string prefix checks. Use the URL object with a known base and allow only schemes your feature needs. For ordinary links in this lesson, the allowlist is http:, https:, and mailto:. Reject javascript:, data:, unknown protocols, and parse errors.
function safeUrlAttribute(raw, base = "https://completefrontend.com/javascript/xss") { try { const url = new URL(raw, base); if (["http:", "https:", "mailto:"].includes(url.protocol)) return url.href; } catch { return "about:blank"; } return "about:blank";} console.log(safeUrlAttribute("/profile?user=ada"));console.log(safeUrlAttribute("mailto:help@example.com"));console.log(safeUrlAttribute("javascript:console.log('XSS ran')"));The safest rendering path is often createElement, textContent, and known-safe attributes. Review modifying the DOM and attributes and properties when you need to build nodes by hand.
Sanitize HTML when markup is required
ALLOWLISTSometimes the product really does allow markup: rich-text comments, CMS snippets, formatted help text, link previews, or a profile bio with bold and links. In that case, escaping would show the tags instead of formatting. A sanitizer parses the HTML, keeps a small allowlist, removes unsafe attributes, validates URL schemes, and serializes the clean result.
import DOMPurify from "dompurify"; const clean = DOMPurify.sanitize(dirtyHtml, { USE_PROFILES: { html: true }, ALLOWED_TAGS: ["b", "i", "a", "p", "ul", "li"], ALLOWED_ATTR: ["href"],}); preview.innerHTML = clean;DOMPurify’s documented API is DOMPurify.sanitize(dirty), with an optional configuration object such as { USE_PROFILES: { html: true } }. It is a maintained library and the production answer for broad browser support. It is not installed in this workspace, so the snippet is intentionally non-runnable.
The platform is adding built-in sanitizing through the HTML Sanitizer API and Element.setHTML(). A web check for this lesson found current Chrome and Edge support, Firefox support in current releases, and Safari/WebView support as the main compatibility question; it is not as universal as textContent. Feature-detect with "setHTML" in Element.prototype and keep a library fallback.
const target = document.querySelector("#rich-output");const dirty = '<b>Hello</b> <img src=x onerror="console.log(1)">'; if ("setHTML" in Element.prototype) { target.setHTML(dirty); console.log("setHTML output:", target.innerHTML);} else { console.log("setHTML unavailable: use DOMPurify or another sanitizer");}Regex-based “sanitizers” fail because HTML is not a regular language in the way browsers parse it. Attributes, entity decoding, nested tags, namespace rules, and broken markup all change the tree the browser builds. This links back to when not to use a regex: parsing languages with nested structure is the wrong job for a quick pattern.
const dirty = '<img src=x onerror="console.log(1)"><scr' + 'ipt>console.log(2)</scr' + 'ipt>';const withoutScripts = dirty.replace(/<script.*?>.*?<\/script>/gi, "");console.log(withoutScripts);Step through the decisions behind the teaching sanitizer. It is an allowlist example for learning; production code should use a maintained sanitizer such as DOMPurify.
script
const allowedSchemes = new Set(["http:", "https:", "mailto:"]);const sample = 'Hello <b onclick="x()">bold</b> <a href="javascript:alert(1)">bad</a> <a href="https://example.com">ok</a>'; function sanitizeTeachingHtml(html) { const parsed = parseToNodes(html); const cleanNodes = parsed.flatMap(cleanNode); return cleanNodes.join("");} console.log(sanitizeTeachingHtml(sample)); function parseToNodes(_html) { return [ { type: "text", text: "Hello " }, { type: "element", tag: "B", children: [{ type: "text", text: "bold" }] }, { type: "text", text: " " }, { type: "element", tag: "A", href: "javascript:alert(1)", children: [{ type: "text", text: "bad" }] }, { type: "text", text: " " }, { type: "element", tag: "A", href: "https://example.com", children: [{ type: "text", text: "ok" }] }, ];} function cleanNode(node) { if (node.type === "text") return [node.text]; if (!allowedTags.has(node.tag)) return node.children.flatMap(cleanNode); const href = node.tag === "A" && allowedUrl(node.href) ? ` href="${node.href}" rel="noopener noreferrer"` : ""; const body = node.children.flatMap(cleanNode).join(""); return [`<${node.tag.toLowerCase()}${href}>${body}</${node.tag.toLowerCase()}>`];} function allowedUrl(value) { try { return allowedSchemes.has(new URL(value, "https://example.com/").protocol); } catch { return false; }}function isSafeUrl(value) { try { const url = new URL(value, "https://example.com/"); return ["http:", "https:", "mailto:"].includes(url.protocol); } catch { return false; }} function sanitizeTeachingHtml(dirtyHtml) { const allowedTags = new Set(["B", "I", "A"]); const dropWithChildren = new Set(["SCRIPT", "STYLE", "IFRAME", "OBJECT", "EMBED", "SVG", "MATH"]); const parser = new DOMParser(); const parsed = parser.parseFromString(dirtyHtml, "text/html"); const output = document.createElement("template"); function cleanInto(node, parent) { if (node.nodeType === Node.TEXT_NODE) { parent.append(document.createTextNode(node.textContent ?? "")); return; } if (node.nodeType !== Node.ELEMENT_NODE) return; if (dropWithChildren.has(node.tagName)) return; if (!allowedTags.has(node.tagName)) { node.childNodes.forEach((child) => cleanInto(child, parent)); return; } const clean = document.createElement(node.tagName.toLowerCase()); if (node.tagName === "A") { const href = node.getAttribute("href")?.trim() ?? ""; if (isSafeUrl(href)) { clean.setAttribute("href", href); clean.setAttribute("rel", "noopener noreferrer"); } } node.childNodes.forEach((child) => cleanInto(child, clean)); parent.append(clean); } parsed.body.childNodes.forEach((child) => cleanInto(child, output.content)); const wrapper = document.createElement("div"); wrapper.append(output.content.cloneNode(true)); return wrapper.innerHTML;} const dirty = 'Hello <b onclick="x()">bold</b> <i>friend</i> ' + '<a href="javascript:alert(1)" onclick="x()">bad</a> ' + '<a href="https://example.com">ok</a>' + '<' + 'script>console.log("x")</' + 'script><img src=x onerror="x()">';console.log(sanitizeTeachingHtml(dirty));The allowlist sanitizer above is real code for learning: it parses, clones allowed tags, strips attributes, and validates links. It is still not production-hardened, not audited, and not a substitute for a maintained sanitizer with a published threat model.
| Approach | Example | Use it when |
|---|---|---|
| Library sanitizer | DOMPurify.sanitize(dirty, config) | Maintained allowlist, broad compatibility, still needs correct configuration and no unsafe post-processing. |
| Built-in Sanitizer API | element.setHTML(dirty) when available | Feature-detect. Current support is not as universal as ordinary DOM APIs, especially in older browsers and embedded webviews. |
| Regex replacement | html.replace(/<script.*>/g, '') | Not a sanitizer. Browser parsing, attributes, nesting, and encodings make regex rules incomplete. |
| Teaching sanitizer | The tiny allowlist in this lesson | Useful for learning the decisions. Not production-hardened or threat-modeled. |
Trusted Types
CSP GUARDRAILTrusted Types is a browser guardrail for DOM XSS. With the CSP directive require-trusted-types-for 'script', dangerous sinks such as innerHTML reject plain strings. Code must pass a TrustedHTML object made by a named policy, usually after sanitizing.
if (!window.trustedTypes) { throw new Error("Trusted Types are not available in this browser");} const target = document.querySelector("#trusted-target");try { target.innerHTML = "<b>plain string</b>"; throw new Error("Trusted Types enforcement is not active");} catch (error) { console.log(error.name); throw error;}if (window.trustedTypes) { const policy = trustedTypes.createPolicy("lesson", { createHTML(value) { return value; }, }); const target = document.querySelector("#trusted-target"); target.innerHTML = policy.createHTML("<b>Trusted Types policy output</b>"); console.log(target.textContent);} else { console.log("Trusted Types unavailable: keep escaping and sanitizing");}Policies are review points. A production policy should call a sanitizer and return the sanitized string. Avoid a default policy that blindly trusts every string; that only hides mistakes. A web check for this lesson found broad current support in Chromium-based browsers, with current Firefox and Safari status improving, but older browsers and embedded webviews vary. Always feature-detect window.trustedTypes and keep your escaping and sanitizing defenses.
Content Security Policy
DEFENSE IN DEPTHContent Security Policy, or CSP, is a browser policy delivered as an HTTP header or a limited meta tag. It tells the browser which scripts, frames, objects, bases, and connections are allowed. CSP is defense in depth: it reduces impact when a rendering bug slips through, but it does not replace escaping, URL validation, sanitizing, or code review.
Content-Security-Policy: default-src 'self'; script-src 'nonce-r4nd0m' 'strict-dynamic'; object-src 'none'; base-uri 'self'; frame-ancestors 'none'; require-trusted-types-for 'script'; trusted-types lesson dompurify;default-src is the fallback. script-src should use nonces or hashes for scripts you intentionally ship; 'strict-dynamic' lets nonce-trusted scripts load their own trusted descendants in modern browsers. object-src 'none' removes old plugin surfaces. base-uri 'self' blocks injected <base> tags from rewriting relative URLs. frame-ancestors controls who may embed your page. require-trusted-types-for 'script' turns on the Trusted Types sink checks.
'unsafe-inline' in script-src allows inline scripts and inline event handlers, which removes one of CSP’s strongest protections against XSS. Use nonces or hashes instead, roll out with Content-Security-Policy-Report-Only, inspect reports, then enforce.
Real-world patterns
Comment widgets, search result pages, rich-text editors, link previews, support inboxes, notification templates, and analytics dashboards all render data that someone else can influence. The pattern is boring on purpose: identify the source, choose the safest sink, validate URLs, sanitize only when markup is required, and add Trusted Types plus CSP so mistakes are easier to catch.
// React escapes text in JSX by default.function Comment({ author, body }) { return <article><h3>{author}</h3><p>{body}</p></article>;} // Escape hatches put you back in charge of sanitizing.function RichComment({ sanitizedHtml }) { return <article dangerouslySetInnerHTML={{ __html: sanitizedHtml }} />;} // Vue's {{ message }} escapes. v-html renders HTML and needs sanitized input.Frameworks help but do not make you invincible. React escapes text in JSX. Vue’s mustache syntax escapes text. But React’s dangerouslySetInnerHTML, Vue’s v-html, Svelte’s {@html ...} block, raw DOM refs, and string templates put you back in charge. Review template literals, eval and dynamic code, postMessage origin checks, web storage, and the concurrent tiny reactive UI lesson when you build renderers.
Plain text UI
Use textContent, JSX text interpolation, or equivalent safe bindings. Do not parse markup just to show a name, query, or status.
User links
Use new URL(), allow known schemes, consider allowed hosts, and set rel="noopener noreferrer" for external links.
Rich text
Sanitize with a maintained allowlist, store either the original plus sanitized render cache or a trusted clean representation, and re-sanitize when policies change.
Application shell
Enable CSP reports, remove inline handlers, adopt Trusted Types, and keep secrets out of places JavaScript can read unless the app truly needs them there.
Common misconceptions
- “Scripts inserted with innerHTML do not run, so innerHTML is safe.” Event handler attributes, dangerous URLs, and other active markup still matter.
- “Escaping once makes data safe everywhere.” Escaping is context-specific. HTML, URL, JavaScript, and CSS contexts are different.
- “Sanitizing means removing script tags.” A sanitizer needs an allowlist for tags, attributes, and URL schemes. Regex removal is not enough.
- “Trusted Types sanitizes for me.” Trusted Types rejects plain strings. Your policy must still sanitize or construct safe HTML.
- “CSP fixes XSS.” CSP limits what can run and reports violations. It is a backstop, not the primary rendering defense.
- “Frameworks eliminate XSS.” Auto-escaping helps until you use escape hatches, raw DOM APIs, unvalidated URLs, or unsafe templates.
| Defense | Stops | Does not stop |
|---|---|---|
textContent / innerText | Writes text, not markup | Default for comments, search results, labels, and API strings. |
| HTML escaping | Turns <, >, &, and quotes into entities for one HTML context | Use when you must build an HTML string from text. Context still matters. |
| Sanitizer | Parses HTML and keeps only allowed elements, attributes, and URL schemes | Use for rich-text editors, bios, formatted comments, or CMS snippets. |
| Trusted Types | Requires dangerous DOM sinks to receive TrustedHTML from a policy | Use to make accidental innerHTML = string fail during development and production. |
| CSP | Browser policy that restricts scripts, objects, bases, frames, and reports violations | Defense in depth. It limits impact; it does not replace safe rendering. |
Practice exercises
5 EXERCISESReplace the dangerous sink with the safest property for a plain user comment.
function renderComment(container, comment) {
container.innerHTML = comment;
}function renderComment(container, comment) {
container.textContent = comment;
}textContent displays the comment as text. If the comment contains <img onerror=...>, visitors see those characters instead of a new element.
Type the exact string printed by the escaper.
const htmlEscapes = { "&": "&", "<": "<", ">": ">", '"': """, "'": "'" };
function escapeHtml(value) {
return String(value).replace(/[&<>"']/g, (character) => htmlEscapes[character]);
}
console.log(escapeHtml('<b title="Tom & Jerry">Hi</b>'));The output is <b title="Tom & Jerry">Hi</b>. It is safe for an HTML text context.
Predict the safe fallback for a dangerous scheme.
function safeUrlAttribute(raw, base = "https://completefrontend.com/javascript/xss") {
try {
const url = new URL(raw, base);
if (["http:", "https:", "mailto:"].includes(url.protocol)) return url.href;
} catch {}
return "about:blank";
}
console.log(safeUrlAttribute("javascript:alert(1)"));The guard returns about:blank. A rejected URL should become an inert fallback rather than being assigned to href or src.
Name the policy method that creates reviewed HTML for dangerous sinks.
const policy = trustedTypes.createPolicy("comments", {
// Which method returns reviewed TrustedHTML?
});const policy = trustedTypes.createPolicy("comments", {
createHTML(value) {
return DOMPurify.sanitize(value, { USE_PROFILES: { html: true } });
},
});createHTML is the review point for values that will enter HTML sinks. In production it should call a sanitizer.
Name the dangerous sink in this DOM-based XSS bug.
function showSearchFromHash() {
const query = decodeURIComponent(location.hash.slice(1));
results.innerHTML = "Search: " + query;
}function showSearchFromHash() {
const query = decodeURIComponent(location.hash.slice(1));
results.textContent = "Search: " + query;
}The dangerous sink is results.innerHTML. Hashes are attacker-controlled URLs, so render the query as text.
Check your understanding
8 QUESTIONSQuestion 1 of 8What is cross-site scripting in this lesson's defensive sense?
Choose an answer to see the explanation.
Question 2 of 8Which assignment is the safe default for a plain user comment?
Choose an answer to see the explanation.
Question 3 of 8What does this URL guard print?
Read the code, then predictfunction safeUrlAttribute(raw, base = "https://completefrontend.com/javascript/xss") { try { const url = new URL(raw, base); if (["http:", "https:", "mailto:"].includes(url.protocol)) return url.href; } catch {} return "about:blank"; } console.log(safeUrlAttribute("javascript:console.log('XSS ran')"));Choose an answer to see the explanation.
Question 4 of 8Why is DOMPurify shown as non-runnable code here?
Choose an answer to see the explanation.
Question 5 of 8Under
require-trusted-types-for 'script', what happens toelement.innerHTML = '<b>x</b>'?Choose an answer to see the explanation.
Question 6 of 8Which CSP choice most clearly weakens XSS defense?
Choose an answer to see the explanation.
Question 7 of 8What does this escaped output prove?
Read the code, then predictconst htmlEscapes = { "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }; function escapeHtml(value) { return String(value).replace(/[&<>"']/g, (character) => htmlEscapes[character]); } console.log(escapeHtml('<img src=x onerror="console.log(1)">'));Choose an answer to see the explanation.
Question 8 of 8Which framework statement is accurate?
Choose an answer to see the explanation.
Key takeaways
- XSS is a data-to-code bug. Start every review by tracing untrusted sources to parsing or execution sinks.
- Use
textContent, safe bindings, and DOM construction for plain text. Avoid HTML strings when you can. - Escape for the exact context; validate URLs with
new URL()and a scheme allowlist. - When user HTML is required, use a maintained allowlist sanitizer. The teaching sanitizer shows the decisions, not a production guarantee.
- Trusted Types makes dangerous DOM sinks reject plain strings, and CSP limits what can run. They are defense in depth, not replacements for safe rendering.
One-line summary: keep untrusted data from running as code by choosing safe sinks first, sanitizing only when markup is required, and adding browser policies as guardrails.
Up next: CSRF, cookies, and tokens. That lesson protects authenticated requests after this one keeps the page from running attacker-controlled code.