Why DOM XSS keeps slipping through

DOM-based cross-site scripting (DOM XSS) occurs when user-controlled data from a source such as a username or a URL fragment reaches a sink like eval() or the .innerHTML setter. Sinks are dangerous because they can execute arbitrary JavaScript. Unlike server-side XSS, which stems from insecure HTML generation on the backend, DOM XSS has its root cause in client-side code that passes untrusted content to these APIs.

Trusted Types address this class of vulnerability by making risky sink functions safe by default. When enabled, the browser refuses to accept plain strings in these contexts and instead requires a dedicated Trusted Type object. The API is available in Chrome 83+, Edge 83+, Firefox 148+, and Safari 26+, with a polyfill available for older browsers.

Sinks that Trusted Types lock down

Trusted Types restrict a well-defined set of sink categories:

  • Script manipulation: <script src> and setting text content of <script> elements.
  • HTML generation from strings: innerHTML, outerHTML, insertAdjacentHTML, <iframe> srcdoc, document.write, document.writeln, and DOMParser.parseFromString.
  • Plugin content: <embed src>, <object data>, and <object codebase>.
  • Runtime JavaScript compilation: eval, setTimeout, setInterval, and new Function().

With Trusted Types active, passing a raw string to any of these sinks throws a TypeError. Instead, the data must first be wrapped in the appropriate type: TrustedHTML for HTML-consuming sinks, plus TrustedScript and TrustedScriptURL for the others.

Rolling out Trusted Types incrementally

Migration starts in report-only mode so you can find violations without breaking the application.

Collect violation reports

Deploy a report collector such as the open-source reporting-api-processor or go-csp-collector, or use a commercial equivalent. For local debugging, observe violations with a ReportingObserver or a standard event listener in the browser.

Add a report-only CSP header

Add the following HTTP response header to documents you plan to migrate:

Content-Security-Policy-Report-Only: require-trusted-types-for 'script'; report-uri //my-csp-endpoint.example

Violations are then sent to //my-csp-endpoint.example while the site continues to operate normally.

Understand the violation reports

Each time a string hits a locked-down sink, the browser emits a report to the configured report-uri. A typical report identifies the script URL, line number, sink name, and the start of the offending string. This gives you a precise map of code paths that need changes.

Fixing the violations

There are four strategies for remediating Trusted Types violations, in order of preference.

Rewrite the offending code

The simplest fix is removing the vulnerable sink call entirely or restructuring the code so the sink is never used. Many DOM XSS patterns, such as building markup via string concatenation, can be replaced with safe DOM construction APIs.

Use a Trusted-Types-aware library

Libraries that natively support Trusted Types can handle sanitization for you. DOMPurify, for instance, returns sanitized output wrapped in a TrustedHTML object, so the browser never sees a violation.

Create a Trusted Type policy

When you can't eliminate the offending code and no library fits, define a policy. Policies are factories that produce Trusted Types while enforcing security rules on their input:

if (window.trustedTypes && trustedTypes.createPolicy) { // Feature testing
  const escapeHTMLPolicy = trustedTypes.createPolicy('myEscapePolicy', {
    createHTML: string => string.replace(/\</g, '&lt;')
  });
}

The resulting myEscapePolicy creates TrustedHTML objects via its createHTML() function. The policy above HTML-escapes < characters, preventing the creation of new HTML elements. Once defined, you apply the policy where you previously passed a raw string:

const escaped = escapeHTMLPolicy.createHTML('<img src=x onerror=alert(1)>');
console.log(escaped instanceof TrustedHTML);  // true
el.innerHTML = escaped;  // '&lt;img src=x onerror=alert(1)>'

Use a default policy as a last resort

When a third-party library loaded from a CDN can't be modified, a default policy handles violations automatically. A policy named default is invoked wherever a string reaches a Trusted-Type-only sink:

if (window.trustedTypes && trustedTypes.createPolicy) { // Feature testing
  trustedTypes.createPolicy('default', {
    createHTML: (string, sink) => DOMPurify.sanitize(string, {RETURN_TRUSTED_TYPE: true})
  });
}

Enforcing Trusted Types

Once the violation reports dry up, switch from the report-only header to an enforcing Content Security Policy:

Content-Security-Policy: require-trusted-types-for 'script'; report-uri //my-csp-endpoint.example

At this point, the only remaining DOM XSS attack surface is the code inside your own policies. You can further shrink that surface by restricting which policy names are allowed to be created via the Trusted Types CSP directive.