Beyond script-src: how CSP Trusted Types locks down DOM XSS

Trusted Types reached cross-browser support in early 2026 with Firefox completing the picture. Here is what it is, why it matters, and how to implement it step by step.

Beyond script-src: how CSP Trusted Types locks down DOM XSS

CSP has always been great at controlling what loads on your page. You can restrict which scripts run, which domains are allowed to serve resources, and where your page can be embedded. But there was always a blind spot: once a script is running, CSP has no say in what it does with data.

Trusted Types closes that gap. It is a browser security API, proposed by Google in 2019, that significantly reduces the DOM XSS attack surface by enforcing safe DOM manipulation at the sink level. CSP provides the enforcement and configuration layer on top, through two directives: trusted-types to allowlist policy names, and require-trusted-types-for to enforce that sinks reject raw strings.

Until recently, Trusted Types was not universally supported. Chrome and Edge have supported it since version 83 in May 2020. Safari joined in version 26, released in September 2025. Firefox completed the picture in February 2026, at which point Trusted Types reached Baseline status, meaning it now works across all major browsers and is finally practical to adopt without caveats.

The problem: sinks

To understand Trusted Types, you first need to understand the concept of an injection sink. A sink is any browser API that accepts a string and interprets it as HTML, JavaScript, or a URL. These are dangerous because the browser trusts whatever you pass in, regardless of where it came from.

Common sinks include:

element.innerHTML = data;         // parsed as HTML
eval(data);                       // executed as JavaScript
scriptElement.src = data;         // loaded and executed as a script

Compare that to a safe alternative like textContent, which is not a sink. The browser never hands the value to an HTML parser or JavaScript engine, it simply renders it as characters on screen:

element.textContent = data;       // always treated as plain text, never parsed

Knowing what qualifies as a sink is the first step in auditing your codebase.

Source-level vs sink-level protection

You probably already sanitize data before it reaches the DOM. That is source-level protection: clean the data when it comes in, and trust that it stays clean as it flows through your application.

The problem is that this relies on every developer, every dependency, and every future code change remembering to sanitize. One lapse, one compromised npm package, one junior developer writing element.innerHTML = data on a Friday afternoon, and the protection is gone.

Trusted Types is sink-level protection. The browser refuses to accept a raw string at any dangerous sink, regardless of where the data came from or how many hands it passed through. It does not matter if a third-party library skips sanitization. The write simply does not happen.

This is not a replacement for sanitizing your inputs on the server side. Server-side escaping prevents stored and reflected XSS, where malicious content is injected into the HTML before it even reaches the browser. Trusted Types operates client-side, after the page has loaded. They protect against different things and work best together.

How Trusted Types works

You define a named policy that acts as a gatekeeper for all data flowing into dangerous sinks. Any value that reaches a sink must have passed through your policy first, or the browser throws a TypeError.

const policy = trustedTypes.createPolicy('myPolicy', {
    createHTML: (input) => DOMPurify.sanitize(input),
});

element.innerHTML = policy.createHTML(data);  // accepted: TrustedHTML object
element.innerHTML = data;                     // TypeError: requires TrustedHTML

The policy produces typed objects (TrustedHTML, TrustedScript, TrustedScriptURL) that the browser accepts in sinks instead of raw strings. Your code does not change much, but the guarantee changes completely.

There are three types:

  • TrustedHTML for anything parsed as HTML (innerHTML, outerHTML, document.write). The most common one you will encounter.
  • TrustedScript for APIs that interpret their input as JavaScript, such as eval() or HTMLScriptElement.text. Rare in modern code. Your createScript handler should almost always throw rather than pass input through.
  • TrustedScriptURL for APIs that interpret their input as the URL of a script, such as HTMLScriptElement.src or new Worker(url), but only when set dynamically via JavaScript. A static <script src=""> in HTML is not a sink. Worth noting that a well-configured script-src CSP directive already covers most of this attack surface, making TrustedScriptURL the least urgent of the three.

Two directives, one mechanism

trusted-types alone only controls which policy names are allowed. You also need require-trusted-types-for to actually enforce that sinks must receive typed objects instead of raw strings.

Content-Security-Policy: trusted-types myPolicy; require-trusted-types-for 'script';

With this in place, any code attempting to write a raw string to a sink will throw a TypeError, including code in third-party libraries that has no awareness of Trusted Types.

The policy is not magic

The browser enforces the funnel. What happens inside the funnel is entirely your responsibility.

This policy is technically valid:

const policy = trustedTypes.createPolicy('myPolicy', {
    createHTML: (input) => input,  // no sanitization
});

The browser sees a named policy and treats the output as TrustedHTML. You have satisfied the mechanism without gaining any actual security, with a false sense of safety on top. Adopting Trusted Types without a real sanitizer in your policy is like installing a lock on your door but leaving the key in it.

A good option for the sanitizer inside your policy is DOMPurify. It has been around since 2014, is maintained by the security researchers at Cure53, and is battle-tested against exactly the edge cases you would miss writing your own sanitizer. Here's an example:

import DOMPurify from 'dompurify';

const policy = trustedTypes.createPolicy('myPolicy', {
    createHTML: (input) => DOMPurify.sanitize(input),
});

element.innerHTML = policy.createHTML(data);

If you prefer not to use a library, the browser's native Sanitizer API is an emerging alternative that requires no external dependency. It is still experimental and not yet broadly supported, but worth keeping an eye on as an eventual built-in option.

Sometimes the answer is not a policy

Before reaching for a policy, ask yourself whether the sink actually needs to handle HTML at all. This is one of the most valuable side effects of a Trusted Types audit.

Consider this typical pattern:

$.ajax({
    url: '/api/notifications',
    success: function(data) {
        $('#notification-bar').html(data.message);
        $('#user-name').html(data.username);
        $('#announcement').html(data.announcement);
    }
});

All three fields use .html(), probably out of habit. But a username is never HTML. A notification message rarely needs markup. Only an announcement from an admin might legitimately contain formatting. A proper rewrite:

$.ajax({
    url: '/api/notifications',
    success: function(data) {
        $('#notification-bar').text(data.message); // plain text, no sink
        $('#user-name').text(data.username); // plain text, no sink
        $('#announcement').html(policy.createHTML(data.announcement)); // HTML, policy applied
    }
});

Two sinks removed entirely. One handled correctly with a policy. Trusted Types makes this question impossible to skip.

Step 1: Audit your sinks

Search your codebase for innerHTML, outerHTML, document.write, eval, setTimeout used with a string, new Function, and dynamic script.src. In jQuery codebases, also check .html(), .append(), .prepend(), .after(), .before(), and $(htmlString).

For each one, ask: does this actually need to render HTML? If not, switch to .text() or textContent and move on.

Step 2: Deploy in report-only mode

Add the Trusted Types directives in a separate Content-Security-Policy-Report-Only header without touching your existing enforced CSP:

Content-Security-Policy: default-src 'self'; script-src 'self' https://cdn.example.com;
Content-Security-Policy-Report-Only: trusted-types myPolicy; require-trusted-types-for 'script';

Both headers can be sent simultaneously. Your existing policy keeps enforcing as normal while Trusted Types runs in observation mode alongside it.

Step 3: Collect and measure violations

Configure a reporting endpoint to receive violation reports. When a Trusted Types violation fires, the browser POSTs a JSON payload. Using the modern Reporting API (report-to), it looks like this:

{
  "blockedURL": "trusted-types-sink",
  "columnNumber": 82395,
  "disposition": "report",
  "documentURL": "https://example.com/",
  "effectiveDirective": "require-trusted-types-for",
  "lineNumber": 2,
  "originalPolicy": "trusted-types myPolicy; require-trusted-types-for 'script'; report-to default",
  "referrer": "",
  "sample": "Element innerHTML|<form></form>",
  "sourceFile": "https://example.com/js/jquery.min.js",
  "statusCode": 200
}

Note that the older report-uri format uses hyphenated field names like document-uri, blocked-uri, and script-sample instead. URIports supports both formats.

The sourceFile, lineNumber and columnNumber tell you exactly where the violation occurred. The sample field shows the sink name followed by the first 40 characters of the violating value, automatically included by the browser for Trusted Types violations.

Collecting and making sense of these reports at scale is where a dedicated tool pays off. URIports collects these reports in real time, aggregates them across your users, and presents them in a clear dashboard that cuts through the raw JSON. You can search, filter, and see exactly which sinks are being hit, how often, and from which source files.

Noise reduction is built in. URIports ranks violations by how widespread they are. A violation reported by a single user is likely a local browser extension or misconfiguration. When the same violation starts appearing across many users, that is a genuine problem worth investigating, and that is when you get notified via email, webhook, or Telegram.

Step 4: Fix your sinks

Work through the violations. Remove sinks that do not need to be sinks. Wrap the ones that do with your named policy.

For third-party libraries that internally use sinks and are not Trusted Types aware, the default policy acts as a catch-all:

trustedTypes.createPolicy('default', {
    createHTML(value) {
        console.log('Please refactor this code');
        return DOMPurify.sanitize(value);
    },
});

The console.log is intentional during migration. Every time it fires, it tells you exactly which code paths are still relying on the default policy and need to be refactored. The browser automatically routes any raw string through the default policy when no other policy handles it. If the handler returns null or undefined, the browser throws a TypeError anyway. If it returns a value, that value is used. This makes the default policy a useful migration tool: you can log, sanitize, or selectively reject values, but it is not a blanket pass-through. Treat it as a temporary aid while you work through your dependencies, not a permanent solution.

Step 5: Enforce

Once report-only shows no more violations, flip to enforcement by moving the Trusted Types directives into your main CSP header:

Content-Security-Policy: default-src 'self'; script-src 'self' https://cdn.example.com; trusted-types myPolicy; require-trusted-types-for 'script';

Step 6: Keep monitoring

Enforcement is not the end. New code, new dependencies, and updated CDN libraries can introduce new violations at any time. Keep reporting active.

A sudden spike in violations after enforcement is a signal worth investigating. It could be an updated dependency that changed behavior, a CDN library pinned to latest rather than a specific version, or in a more serious scenario, a compromised dependency that was actively modified. This is exactly the Polyfill.io scenario: a widely used CDN script was altered to inject malicious code. With Trusted Types enforced and reporting active, that attempt would have been blocked and reported immediately.

In that sense, your violation reports become an intrusion detection system. URIports makes that signal actionable.

Trusted Types and frameworks

If you use a modern JavaScript framework, Trusted Types support may already be partly handled for you. Angular has had built-in Trusted Types support since Angular 11. When using the standard Angular template system, the framework produces typed objects for DOM operations automatically. You do still need to add angular to your trusted-types CSP allowlist.

React is a different story. React largely avoids dangerous sinks by design, and the naming of dangerouslySetInnerHTML is intentional. However, dangerouslySetInnerHTML does write to an HTML sink internally, so when Trusted Types enforcement is active, React will throw a TypeError unless the value passed is a TrustedHTML object. You will need a policy and a sanitizer in place wherever you use it.

For any framework, the audit step remains important. Third-party plugins and component libraries can introduce their own sinks regardless of what the core framework does.

Should you use Trusted Types?

Not necessarily for every project. There is a real implementation cost: auditing sinks, updating code, handling third-party libraries, and maintaining your policy over time.

It is a strong fit if you have user-generated content that reaches the DOM, a team where multiple developers touch the frontend, heavy use of third-party dependencies, or security is central to your product's trust proposition (think banks, fintech, healthcare, or legal platforms).

It is probably overkill for a mostly static site with no user-generated content, or a small project where you have complete oversight of every line of JavaScript.

The key thing to understand is that Trusted Types is one layer in a security stack, not a standalone solution. It works alongside script-src and server-side output escaping, each covering what the others cannot. A third layer worth combining it with is Subresource Integrity (SRI). SRI lets you pin a hash to any externally loaded script:

<script src="https://cdn.example.com/library.js"
        integrity="sha384-abc123..."
        crossorigin="anonymous"></script>

If the file on the CDN changes, whether by an innocent update or a supply chain attack, the browser refuses to load it entirely. SRI stops the threat before the script runs. Trusted Types catches anything that slips through. Together they give you defence in depth that covers the full chain.

With reporting in place via URIports, you go from hoping your CSP is working to actually knowing.

Further reading