cf.completefrontendCode editorOpen lab
THE JAVASCRIPT FIELD GUIDE

XSS & safe DOM updates

Learn how XSS turns untrusted data into code, then prevent it with textContent, escaping, sanitizers, Trusted Types, and CSP.

By the end, you can
  • 01
    Think like a defenderName where untrusted data enters a page and which DOM sinks can execute it.
  • 02
    Render data safelyChoose textContent, URL validation, escaping, or an allowlist sanitizer for the context.
  • 03
    Add 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.

Plain definition

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.

Real-life analogyA building with labels, locks, and approved contractors

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: textContent prints data as text
In real life: Contractor who can change walls
In JavaScript: innerHTML parses 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 DEMO

XSS 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.

Common XSS sources and dangerous sinksJavaScript
// 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 code

The 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.”

Safe XSS sink comparison
Sink comparison sourcePop out in the code editor (opens in a new tab)JavaScript
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);
Sandbox resultinnerHTML + inline handler

Waiting for the sandbox to run.

Try it yourself
Choose the sink to test in a sandboxed iframe

The iframe uses sandbox="allow-scripts" only. It has an opaque origin and cannot read CompleteFrontend storage.

The sandbox parses the button and .click() runs its harmless inline handler. Result: Waiting for the sandbox to run.

The payloads are harmless: they only set a flag inside the sandbox and report yes/no to the lesson page. No untrusted HTML is inserted into the lesson document.

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.

Safe sink or dangerous sink?
  • node.textContent = userInput
  • node.innerHTML = userInput
  • node.outerHTML = apiHtml
  • node.insertAdjacentHTML('beforeend', html)
  • link.href = userInput
  • iframe.src = userInput
  • eval(userInput)
  • setTimeout(userInput, 0)
Try it yourself
0 of 8 correct

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.

Choose a category for every card. You can change an answer at any time; Reset clears them all.

Escape for the right context

STEP THROUGH

If 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 escapeHtml
Step 0 of 6Ready
Your turn: follow the blue line

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.

Running in
  1. script
Next: line 1
Click the blue line to take the next stepPop out in the code editor (opens in a new tab)JavaScript
  "&": "&amp;",  "<": "&lt;",  ">": "&gt;",  '"': "&quot;",  "'": "&#39;",}; 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));
CallStoreChangeResultRun = next line. Ran = already executed.
Recent returnsNothing yet. Start with the blue line.
A guided replay recorded from real JavaScript calls, not an engine debugger. Step follows executed statements; Back reviews a snapshot. Reset starts a fresh run.
A real HTML escaper for text and quoted attributesPop out in the code editor (opens in a new tab)JavaScript
const htmlEscapes = {  "&": "&amp;",  "<": "&lt;",  ">": "&gt;",  '"': "&quot;",  "'": "&#39;",}; 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.

Context decides the defenseJavaScript
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.

Validate URL schemes before assigning href or srcPop out in the code editor (opens in a new tab)JavaScript
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')"));
Best default: no HTML string

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

ALLOWLIST

Sometimes 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.

DOMPurify API shape (not installed in this app)JavaScript
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.

Built-in Sanitizer API status

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.

Feature-detect Element.setHTMLPop out in the code editor (opens in a new tab)JavaScript
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.

A regex that misses the dangerous attributePop out in the code editor (opens in a new tab)JavaScript
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 an allowlist sanitizer
Step 0 of 7Ready
Your turn: follow the blue line

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.

Running in
  1. script
Next: line 1
Click the blue line to take the next stepPop out in the code editor (opens in a new tab)JavaScript
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;  }}
CallStoreChangeResultRun = next line. Ran = already executed.
Recent returnsNothing yet. Start with the blue line.
A guided replay recorded from real JavaScript calls, not an engine debugger. Step follows executed statements; Back reviews a snapshot. Reset starts a fresh run.
Teaching sanitizer with DOMParser and a templatePop out in the code editor (opens in a new tab)JavaScript
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));
Teaching example, not production code

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.

Ways to handle user-provided HTML
ApproachExampleUse it when
Library sanitizerDOMPurify.sanitize(dirty, config)Maintained allowlist, broad compatibility, still needs correct configuration and no unsafe post-processing.
Built-in Sanitizer APIelement.setHTML(dirty) when availableFeature-detect. Current support is not as universal as ordinary DOM APIs, especially in older browsers and embedded webviews.
Regex replacementhtml.replace(/<script.*>/g, '')Not a sanitizer. Browser parsing, attributes, nesting, and encodings make regex rules incomplete.
Teaching sanitizerThe tiny allowlist in this lessonUseful for learning the decisions. Not production-hardened or threat-modeled.

Trusted Types

CSP GUARDRAIL

Trusted 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.

Plain strings fail under Trusted Types CSPPop out in the code editor (opens in a new tab)JavaScript
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;}
Create a policy that returns TrustedHTMLPop out in the code editor (opens in a new tab)JavaScript
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 DEPTH

Content 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.

A strong CSP starting pointtext
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.

Avoid unsafe-inline

'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.

Framework defaults and escape hatchesJSX
// 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.
Which defense solves which problem?
DefenseStopsDoes not stop
textContent / innerTextWrites text, not markupDefault for comments, search results, labels, and API strings.
HTML escapingTurns <, >, &, and quotes into entities for one HTML contextUse when you must build an HTML string from text. Context still matters.
SanitizerParses HTML and keeps only allowed elements, attributes, and URL schemesUse for rich-text editors, bios, formatted comments, or CMS snippets.
Trusted TypesRequires dangerous DOM sinks to receive TrustedHTML from a policyUse to make accidental innerHTML = string fail during development and production.
CSPBrowser policy that restricts scripts, objects, bases, frames, and reports violationsDefense in depth. It limits impact; it does not replace safe rendering.

Practice exercises

5 EXERCISES
Exercise 1 · Warm-upFix a plain comment renderer

Replace the dangerous sink with the safest property for a plain user comment.

Starter codePop out in the code editor (opens in a new tab)JavaScript
function renderComment(container, comment) {
  container.innerHTML = comment;
}

Answer, then press Check. Spacing and letter case don’t matter.

    Exercise 2 · Warm-upPredict escaped output

    Type the exact string printed by the escaper.

    Starter codePop out in the code editor (opens in a new tab)JavaScript
    const htmlEscapes = { "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" };
    function escapeHtml(value) {
      return String(value).replace(/[&<>"']/g, (character) => htmlEscapes[character]);
    }
    console.log(escapeHtml('<b title="Tom & Jerry">Hi</b>'));

    Answer, then press Check. Spacing and letter case don’t matter.

      Exercise 3 · PracticeReject a script URL

      Predict the safe fallback for a dangerous scheme.

      Starter codePop out in the code editor (opens in a new tab)JavaScript
      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)"));

      Answer, then press Check. Spacing and letter case don’t matter.

        Exercise 4 · PracticeWrite a Trusted Types policy shape

        Name the policy method that creates reviewed HTML for dangerous sinks.

        Starter codePop out in the code editor (opens in a new tab)JavaScript
        const policy = trustedTypes.createPolicy("comments", {
          // Which method returns reviewed TrustedHTML?
        });

        Answer, then press Check. Spacing and letter case don’t matter.

          Exercise 5 · ChallengeSpot the DOM XSS in a hash router

          Name the dangerous sink in this DOM-based XSS bug.

          Starter codePop out in the code editor (opens in a new tab)JavaScript
          function showSearchFromHash() {
            const query = decodeURIComponent(location.hash.slice(1));
            results.innerHTML = "Search: " + query;
          }

          Answer, then press Check. Spacing and letter case don’t matter.

            Check your understanding

            8 QUESTIONS
            XSS and safe DOM updates quiz · 8 questionsScore: first tries count
            1. Question 1 of 8What is cross-site scripting in this lesson's defensive sense?

              Choose an answer to see the explanation.

            2. Question 2 of 8Which assignment is the safe default for a plain user comment?

              Choose an answer to see the explanation.

            3. Question 3 of 8What does this URL guard print?

              Read the code, then predictPop out in the code editor (opens in a new tab)JavaScript
              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:console.log('XSS ran')"));

              Choose an answer to see the explanation.

            4. Question 4 of 8Why is DOMPurify shown as non-runnable code here?

              Choose an answer to see the explanation.

            5. Question 5 of 8Under require-trusted-types-for 'script', what happens to element.innerHTML = '<b>x</b>'?

              Choose an answer to see the explanation.

            6. Question 6 of 8Which CSP choice most clearly weakens XSS defense?

              Choose an answer to see the explanation.

            7. Question 7 of 8What does this escaped output prove?

              Read the code, then predictPop out in the code editor (opens in a new tab)JavaScript
              const htmlEscapes = { "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" };
              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.

            8. 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.

            CompleteFrontend Clear concepts. Working examples.