In short

Render untrusted data for the exact place it lands. Use text APIs such as textContent or escaped template interpolation for text; parse URLs and allowlist scheme and host before using them as links; keep untrusted strings out of inline scripts, event handlers and styles; and pass rich HTML through a maintained sanitizer. Review every framework escape hatch, such as dangerouslySetInnerHTML, v-html, |safe or Html.Raw. CSP and Trusted Types enforce these rules; they do not replace them.

  • Text is the default: textContent or framework interpolation shows markup-shaped input as characters.
  • Each context has its own rule: HTML text, attributes, URLs, JavaScript and CSS are different parsers.
  • HTML escaping cannot make a javascript: URL safe; parse the URL and allowlist scheme and host.
  • Every escape hatch that inserts raw HTML needs a sanitizer and a reviewer.
  • Trusted Types, Baseline in current browsers since February 2026, make string assignments to HTML sinks fail; roll them out in report-only mode first.

Verify it

  1. Render a name containing an image tag with an onerror attribute; assert the exact string is visible and no image element exists.
  2. Feed javascript:, a lookalike host, credentials and a non-default port to every link field; only the allowed URL may become an href.
  3. Search templates and components for raw-HTML escape hatches and confirm each input is sanitized or a constant.
  4. Repeat the checks on validation errors, administrator views and email previews, which often use different templates.
  5. Send Content-Security-Policy-Report-Only: require-trusted-types-for 'script' and fix every reported sink before enforcing it.
Applies to

HTML, attribute, URL and script output boundaries. An HTML encoder is not a universal sanitizer.

Fix it in your stack

Tested fixes: the vulnerable code, the fix, a plausible fix that still fails, and the regression tests that tell them apart.

Learning goals and prerequisitesAbout 25 minutes: 7 to read and 18 for the exercise

What you will learn

  • Distinguish text, markup and URL interpretation.
  • Choose safe output interfaces and validate URL semantics.
  • Verify both DOM structure and preserved normal rendering.

Before you start

Front-end and full-stack developers rendering user-supplied data, and reviewers triaging cross-site scripting findings.

  • Identify the parser context where an untrusted value enters a document.
Go to the guided practice

Follow the string into the parser that receives it

Cross-site scripting occurs when an application turns untrusted data into browser-executable content in its own origin. The same characters have different meanings in text, HTML attributes, URLs, JavaScript and CSS. A universal replace-angle-brackets function cannot secure all those contexts. First identify the final sink: is a string assigned as text, parsed as HTML, used as a link destination or inserted into executable source?

For a plain customer name, the requirement is to show characters. Assigning name through textContent creates a text node. Assigning the same name through innerHTML invokes the HTML parser. The supplied string <img src=x onerror="window.demo=1"> therefore becomes visible literal text in the first case and an element with an event handler in the second. Do not judge the vulnerable case only by script tags: event handlers and other active contexts can execute without a script element. Stored input is still untrusted when an administrator later views it.

In the browser, the same reasoning applies to DOM-based XSS, where no server template is involved. Sources such as location.hash, location.search, document.referrer, window.name and postMessage data are attacker-influenced. Sinks such as innerHTML, outerHTML, insertAdjacentHTML, document.write, srcdoc, eval, setTimeout with a string and new Function interpret what they receive. Trace each source to its sink exactly as you would a server value.

Choose an interface that preserves the intended type

For text, use a text node or the framework's ordinary escaped interpolation. For an attribute, use a fixed safe attribute name and the framework's attribute API; validation still matters for attributes that carry URLs or other interpretable values. For a profile link, parse the URL, require an allowed scheme and any required host policy, then assign the resulting URL. HTML escaping does not make javascript: an acceptable link. Avoid placing untrusted data inside inline JavaScript, style source or event-handler attributes.

When a feature genuinely requires rich text, use a maintained sanitizer, such as DOMPurify or the browser's Element.setHTML(), configured for that feature's allowed elements and attributes (the lab at /labs/html-sanitizer/ has you configure one), and sanitize the final representation before the HTML sink. A sanitizer is a parser-aware policy mechanism, not a regular expression deleting a few tag names. Keep the library updated and do not append new untrusted markup after sanitization. CSP can reduce some exploitation paths and reveal violations, but it does not make an unsafe output boundary correct; a test that passes only because one browser blocked execution has not shown correct output handling.

Trusted Types turn the sink rule into an enforced one. With require-trusted-types-for 'script' in the Content Security Policy, assigning a plain string to innerHTML and the other script-capable sinks throws a TypeError, so each remaining sink must go through a named, reviewed policy. The feature has been Baseline across current browsers since February 2026. A policy must still sanitize or encode: one that returns its input unchanged only moves the problem.

Fixedrender-profile.jsJavaScript
// Browser JavaScript: ordinary text and a constrained absolute link.
const label = document.createElement('span');
label.textContent = suppliedName;

const link = document.createElement('a');
const parsed = new URL(suppliedUrl);
if (parsed.protocol !== 'https:' || parsed.hostname !== 'docs.example.test'
    || parsed.username || parsed.password || parsed.port) {
  throw new Error('Unsupported destination');
}
link.href = parsed.href;
link.textContent = 'Read documentation';

Know the escape hatch in your framework

Modern frameworks escape interpolated values by default, so most XSS findings in framework code sit at a deliberate escape hatch. Treat each one as a raw HTML sink: the value must be a constant, or the output of a maintained sanitizer configured for that feature. URL attributes such as href and src need the separate scheme check shown above, whatever the framework; /fix/xss/ has the repair for each common stack.

Know the escape hatch in your framework
FrameworkEscaped by defaultEscape hatch to review
React{value} in JSXdangerouslySetInnerHTML
Vue{{ value }}v-html
AngularInterpolation; [innerHTML] is sanitizedDomSanitizer.bypassSecurityTrustHtml and the other bypassSecurityTrust methods
Svelte{value}{@html value}
Jinja2 and Flask{{ value }} with autoescaping|safe, Markup(), {% autoescape false %}
Django templates{{ value }}|safe, mark_safe(), {% autoescape off %}
Ruby on Rails ERB<%= value %>raw, .html_safe, <%== value %>
ASP.NET Core Razor@valueHtml.Raw()
Thymeleafth:textth:utext
Go html/templateContextual escapingtemplate.HTML, template.JS and template.URL conversions
Laravel Blade{{ $value }}{!! $value !!}
Handlebars{{value}}{{{value}}}

Verify structure and behavior rather than one alert box

Use a test record containing ordinary punctuation, markup-shaped text and a disallowed URL. After rendering, inspect textContent and the DOM tree. For the name field, the exact submitted characters must be visible and no img or script node may be created from them. For the link field, an unsupported scheme must not create a clickable destination. The normal HTTPS documentation link must continue to work. These assertions distinguish a repaired renderer from a feature that simply stopped rendering. To practice the repair in a real DOM, the browser lab at /labs/output-rendering/ has you render a model’s answer as text and add a source link only for an allowed URL; it replays your DOM writes in a real document and checks the text, the elements and the link, including javascript:, lookalike-host, userinfo and port URLs.

Check every presentation of the record: search results, notifications, export previews, error messages and staff dashboards often use different templates. A safe React or template interpolation can be undone by a raw-HTML escape hatch or a third-party component that interprets the string again. Double encoding is a common symptom of storing already-escaped text and escaping it again at output: users see &amp;lt; rather than their original characters. Store the intended data representation and encode for its destination when rendering. Logs and analytics are also output systems and need their own safe viewers.

A real-world failure

Stored cross-site scripting still ships in mature software. CVE-2024-4439 was a stored XSS in WordPress core (up to 6.5.1): a user display name was rendered in the Avatar block without output escaping, and comment avatars made it reachable by unauthenticated visitors. The missing control was contextual output encoding of the display name.

Check transformations and exceptional rendering paths

A value can cross more than one parser. Suppose a JSON response contains a safe string, the page concatenates that string into an HTML fragment and then assigns the fragment through innerHTML. JSON serialization protected the JSON grammar, but the subsequent HTML sink grants a new interpretation. Likewise, a URL that was valid when stored can become unsafe if code later decodes or rewrites it before navigation. Place the control at the final relevant boundary and record which representation it protects.

Error messages are a useful review target because they often echo invalid input outside the normal component system. If the main profile page uses textContent but a validation banner builds 'Invalid name: ' + suppliedName as raw HTML, the same input still executes through the rejected-input path. Test both acceptance and rejection rendering, and repeat the check in administrator views and email previews where template choices may differ.

Avoid treating character removal as a security contract. Removing < and > from every name would change valid text and would not repair a javascript: link. Encoding an already-encoded string twice can also break useful content without improving the intended boundary. Compare exact text nodes and attributes after the browser parses the response. For the exercise's N2 string, the robust result is a single text representation containing the full supplied characters and zero generated img nodes, regardless of whether an image request or event happened to be observable in a particular run.

If the product later chooses to allow a markup subset, that is a new rendering capability. Add a dedicated sanitizer policy and tests; do not mark all previously stored names safe because their source was an authenticated account.

Guided practice

Evaluate three output sinks with complete supplied strings and policies.

Supplied scenario material
Plain text
Name N1 = Ada & Lin
Name N2 = <img src=x onerror="window.demo=1">
URL U1 = https://docs.example.test/help
URL U2 = javascript:window.demo=1
URL U3 = https://docs.example.test.attacker.invalid/help
URL U4 = https://docs.example.test:444/help
Unsafe display: target.innerHTML = name
Safe display: target.textContent = name
URL rule: HTTPS, exact host docs.example.test, no credentials, no non-default port.
Rich-text rule for comparison: strong and em only; no attributes.
  1. 1. Predict the parser behavior

    For N1 and N2, compare textContent with innerHTML. Identify the security-relevant created node.

    Expected result and reasoning

    textContent displays N1 and N2 literally. innerHTML parses N2 into an img element with onerror source. Whether that handler happens to fire in one run is secondary to granting executable markup.

  2. 2. Classify link destinations

    Apply the URL rule to U1–U4. Explain why encoding angle brackets cannot make U2 acceptable.

    Apply the URL rule

    U1 https://docs.example.test/help
    U2 javascript:window.demo=1
    U3 https://docs.example.test.attacker.invalid/help
    U4 https://docs.example.test:444/help

    Expected result and reasoning

    Only U1 passes. U2 fails scheme, U3 fails exact host and U4 fails port. U2 contains no angle brackets to escape; the browser interprets the URL scheme when the link is activated.

  3. 3. Separate text from rich text

    For the literal input <strong>Ada</strong><img src=x>, state the intended output under each feature.

    Expected result and reasoning

    Plain text shows the entire string with angle brackets. Rich text may retain the strong element around Ada and must remove the img element under the stated allowlist. These are different product requirements and require different sinks.

  4. 4. Write a precise regression

    Specify one ordinary, one adversarial and one representation-boundary assertion.

    Expected result and reasoning

    N1 remains Ada & Lin; N2 creates zero img elements and its exact string is visible; the rich-text case creates one strong element and zero img elements. Add URL assertions that only U1 becomes a link. No browser alert is needed to establish these results.

Your deliverable: A sink-by-sink result table with exact text, DOM structure and URL acceptance decisions.

Paper exercise: every string, sink and policy is supplied. The JavaScript above applies the same text and URL rules, and the output-rendering lab checks them in 21 cases, 15 of which render your code’s output in a real DOM.

Save a draft on this device to resume later. Automatic saving begins after you choose Save. Drafts are never sent by this application; use synthetic data.

No draft saved on this device yet.

Repair the code

XSS lab: Render a model’s answer as text instead of active markup

HTML sanitization lab: Configure a sanitizer for user comments

Sources & further reading

The standards, specifications and documentation behind the guidance above. Each is the evidence for a claim on this page; consult it for detail beyond the defaults and versions listed here.

  1. OWASP Cross Site Scripting Prevention
  2. MDN: innerHTML security considerations
  3. MDN: Trusted Types API
  4. OWASP DOM based XSS Prevention Cheat Sheet
  5. React: dangerouslySetInnerHTML
  6. Angular: security and DomSanitizer
  7. NVD: CVE-2024-4439, stored XSS in WordPress core

Reference information

SDLC phase
Development
ASVS 5.0.0 requirements
v5.0.0-1.2.1 · v5.0.0-1.2.2 · v5.0.0-1.3.1 · v5.0.0-3.2.2
OWASP WSTG v4.2 tests
WSTG-INPV-01 · WSTG-INPV-02

Download this guide as Markdown