Trusted Types & DOM XSS Prevention
Permalink to "Trusted Types & DOM XSS Prevention"This workflow is part of Runtime Policy Enforcement & Trusted Types. DOM-based cross-site scripting is the class of XSS that never touches your server: attacker-controlled strings flow through client-side JavaScript into a dangerous DOM sink — element.innerHTML, script.src, eval — and the browser parses them as markup or code. Traditional output encoding and server-side sanitization cannot see this data path because it lives entirely in the browser, which is why DOM XSS remains one of the hardest injection classes to eradicate by review alone.
Trusted Types close the gap structurally. Instead of hoping every developer remembers to sanitize before every sink, the browser refuses to accept a plain string at an injection sink at all — it demands a typed TrustedHTML, TrustedScript, or TrustedScriptURL object that could only have been produced by a policy you explicitly authored. This page walks the full adoption path: inventorying sinks in Report-Only mode, creating policies, routing sinks, enforcing, monitoring, and integrating with DOMPurify. It also draws the boundary between what Trusted Types protect and what they do not.
Prerequisites
Permalink to "Prerequisites"Conceptual Foundation: What Trusted Types Are
Permalink to "Conceptual Foundation: What Trusted Types Are"Trusted Types is a browser security standard specified by the W3C Web Application Security Working Group (Trusted Types, W3C Community Group / WICG specification). It defines three immutable object types — TrustedHTML, TrustedScript, and TrustedScriptURL — and it lets a page tell the browser, via CSP, that the DOM’s injection sinks must be handed one of those objects rather than a raw string.
The mechanism has two moving parts:
- The
require-trusted-types-for 'script'directive turns enforcement on. Once present, every injection sink — the DOM APIs that can turn a string into executing script — throws aTypeErrorwhen handed a plain string. The canonical sinks areElement.innerHTML,Element.outerHTML,HTMLScriptElement.src,HTMLScriptElement.text,document.write,DOMParser.parseFromString,eval, and thesetTimeout/setIntervalstring form. - The
trusted-typesdirective allow-lists which policy names may exist. A policy is a small factory, created bytrustedTypes.createPolicy(name, rules), whose callbacks (createHTML,createScript,createScriptURL) are the only code paths in the entire page permitted to mint a trusted object. Because the browser controls object construction, aTrustedHTMLinstance is unforgeable: seeing one at a sink is proof that your policy code ran and had the chance to sanitize.
The result is a choke point. DOM XSS review stops being “audit every one of hundreds of sink assignments” and becomes “audit the handful of policies.” The Enforcing require-trusted-types-for script reference covers the directive syntax, browser-support matrix, and polyfill wiring in depth.
The diagram below shows the two outcomes once enforcement is on: a string routed through a policy becomes a sanitized TrustedHTML the sink accepts, while a raw string handed straight to the sink is blocked.
Step 1 — Inventory Every Sink in Report-Only Mode
Permalink to "Step 1 — Inventory Every Sink in Report-Only Mode"Never turn on enforcement blind. A Report-Only policy makes the browser record every violation it would have blocked while the page keeps working normally. Deploy this header first and let real traffic populate your report endpoint.
Content-Security-Policy-Report-Only:
require-trusted-types-for 'script';
trusted-types 'none';
report-to tt-violations
Add the matching Report-To (or legacy report-uri) header so the browser knows where to POST:
Report-To: {"group":"tt-violations","max_age":10886400,
"endpoints":[{"url":"https://example.com/reports/trusted-types"}]}
Each blocked sink produces a JSON report whose violated-directive is require-trusted-types-for. Expected shape of a single report body received at your endpoint:
{
"type": "csp-violation",
"body": {
"documentURL": "https://example.com/dashboard",
"violatedDirective": "require-trusted-types-for",
"blockedURL": "trusted-types-sink",
"sample": "innerHTML|<div>hello ${user}</div>",
"sourceFile": "https://example.com/js/widget.js",
"lineNumber": 214
}
}
The sample field names the sink (innerHTML) and shows a truncated prefix of the offending string — enough to locate the assignment in widget.js:214 without leaking the full payload. Collect these for a full traffic cycle (a week is typical) until the stream of new sinks goes quiet.
Step 2 — Create a Trusted Types Policy
Permalink to "Step 2 — Create a Trusted Types Policy"A policy is created once, early in page load, before any code that writes to a sink runs. Each callback you supply is the sanitization choke point for its object type. Omit a callback and the corresponding sink type stays blocked — a safe default.
// Runs before any sink assignment. Feature-detect so non-supporting
// browsers (without the polyfill) don't throw on window.trustedTypes.
if (window.trustedTypes && window.trustedTypes.createPolicy) {
window.trustedTypes.createPolicy('app-ui', {
createHTML: (input) =>
// Sanitize before returning — a policy that returns input
// unchanged provides no protection at all.
DOMPurify.sanitize(input, { RETURN_TRUSTED_TYPE: false }),
createScriptURL: (input) => {
const url = new URL(input, document.baseURI);
const allowed = ['https://cdn.example.com'];
if (!allowed.includes(url.origin)) {
throw new TypeError(`Blocked script URL origin: ${url.origin}`);
}
return url.href;
},
});
}
Expected result — the policy is registered and its name is now reserved. Reading it back in the console confirms creation:
> window.trustedTypes.createPolicy
< function createPolicy()
// A second createPolicy('app-ui', …) call now throws:
// TypeError: Policy "app-ui" already exists.
Registering the name a second time throws by design, which prevents an injected script from silently redefining your sanitizer. You will allow-list this exact name in Step 4.
Step 3 — Route Every DOM Sink Through the Policy
Permalink to "Step 3 — Route Every DOM Sink Through the Policy"Now replace raw string assignments with calls that return trusted objects. Hold a reference to the policy returned by createPolicy and use it at each sink your Step 1 inventory found.
const policy = window.trustedTypes.createPolicy('app-ui', {
createHTML: (input) => DOMPurify.sanitize(input),
});
// Before — throws under enforcement:
// panel.innerHTML = userProvidedMarkup;
// After — the sink receives a TrustedHTML object:
panel.innerHTML = policy.createHTML(userProvidedMarkup);
For a dynamically injected <script>, mint a TrustedScriptURL for the src. This is exactly the join point with Subresource Integrity: the URL is validated by Trusted Types, and the fetched bytes are verified by an integrity attribute.
const s = document.createElement('script');
s.src = policy.createScriptURL('https://cdn.example.com/widget.min.js');
s.integrity =
'sha384-oqVuAfXRKap7fdgcCY5uykM6+R9GqQ8K/uxy9rx7HNQlGYl1kPzQho1wx4JwY8wC';
s.crossOrigin = 'anonymous';
document.head.appendChild(s);
Expected result — the assignment succeeds and DevTools shows the property now holds a typed object rather than a string:
> panel.innerHTML instanceof TrustedHTML
// (checking the trusted object before assignment)
> policy.createHTML('<b>ok</b>') instanceof TrustedHTML
< true
Step 4 — Switch to Enforcing Mode
Permalink to "Step 4 — Switch to Enforcing Mode"Once the Report-Only stream is clean, move the directives into the enforcing Content-Security-Policy header and pin the allow-list to only the policy names you created.
Content-Security-Policy:
require-trusted-types-for 'script';
trusted-types app-ui dompurify;
report-to tt-violations
The trusted-types app-ui dompurify list means only policies named app-ui or dompurify may be created; any createPolicy call with another name now throws. Adding 'allow-duplicates' to the list permits the same name to be created more than once (needed by some bundlers that initialize twice) — use it sparingly.
Expected result — a raw string assignment that slipped through now blocks in the enforcing context, and the console shows the enforcement error:
Uncaught TypeError: Failed to set the 'innerHTML' property on 'Element':
This document requires 'TrustedHTML' assignment.
The full directive-syntax and staged-rollout details are in Enforcing require-trusted-types-for script.
Step 5 — Register a Default Policy for Unroutable Sinks
Permalink to "Step 5 — Register a Default Policy for Unroutable Sinks"Some third-party code writes to sinks you cannot edit. A default policy — created with the reserved name default — is invoked automatically whenever a plain string reaches a sink that has no explicit policy, giving you one last interception point. Treat it as a migration bridge, not a permanent fixture: sanitize inside it and log every call so you can find and fix the unrouted callers.
window.trustedTypes.createPolicy('default', {
createHTML: (input, _type, sink) => {
// Log so you can locate and eventually remove the caller.
reportUnroutedSink(sink, input);
return DOMPurify.sanitize(input);
},
createScriptURL: (input) => {
throw new TypeError(`Unrouted script URL blocked: ${input}`);
},
});
Remember to add default to the trusted-types allow-list. Expected result — an unrouted innerHTML assignment now succeeds after passing through the default policy’s sanitizer, and your reportUnroutedSink telemetry records where it came from, rather than throwing. Because a permissive default policy silently re-opens the DOM XSS hole, keep its createScriptURL throwing (as above) and drive the reportUnroutedSink count to zero before you consider adoption complete.
Configuration Reference
Permalink to "Configuration Reference"| Option | Valid values | Security implication |
|---|---|---|
require-trusted-types-for |
'script' |
The only defined value; turns sink enforcement on. Absent = no enforcement |
trusted-types |
space-separated policy names, 'none', 'allow-duplicates' |
Allow-list of creatable policy names. 'none' forbids all policies (blocks every sink); omit the directive to allow any name |
| Policy name | any string, or default |
The default name is auto-invoked for unrouted sinks; keep it narrow and logged |
createHTML callback |
function returning a sanitized string | Must sanitize; returning input unchanged reintroduces DOM XSS |
createScriptURL callback |
function returning an allowed URL string | Validate origin against an allow-list; pairs with SRI on the fetched bytes |
| Delivery | Content-Security-Policy or -Report-Only header |
Always Report-Only first to inventory sinks without breakage |
report-to group |
name matching a Report-To header group |
Required to collect violation telemetry in both modes |
Integration with DOMPurify
Permalink to "Integration with DOMPurify"DOMPurify is the reference sanitizer for the createHTML path, and it has first-class Trusted Types support. Passing RETURN_TRUSTED_TYPE: true makes DOMPurify.sanitize return a TrustedHTML object directly — DOMPurify creates its own internal policy (named dompurify by default), so its output is assignable to a sink with no extra wrapping.
// DOMPurify returns a TrustedHTML directly — no manual policy needed.
import DOMPurify from 'dompurify';
const clean = DOMPurify.sanitize(dirtyMarkup, { RETURN_TRUSTED_TYPE: true });
commentBody.innerHTML = clean; // TrustedHTML — accepted under enforcement
When you take this route, add dompurify to the trusted-types allow-list (as shown in Step 4) so DOMPurify’s internal policy is permitted to exist. This keeps sanitization and object creation in one audited library rather than split across your own callback. For coordinating this DOM-layer control with fetch-layer and policy-layer controls end to end, see Coordinating SRI, CSP & Trusted Types.
Troubleshooting
Permalink to "Troubleshooting"Uncaught TypeError: Failed to set the 'innerHTML' property on 'Element': This document requires 'TrustedHTML' assignment.
Enforcement is on and code assigned a plain string to innerHTML. Route the assignment through a policy — el.innerHTML = policy.createHTML(str) — or, for third-party code you cannot edit, register the default policy from Step 5. The sourceFile and lineNumber in the matching violation report point at the exact caller.
Refused to create a TrustedTypePolicy named 'x' because it violates the following Content Security Policy directive: "trusted-types a b".
A createPolicy call used a name that is not in the trusted-types allow-list. Add the name to the directive, or rename the policy to an allowed one. This error is also the expected signal that an injected script tried to mint its own policy — if you did not write that createPolicy call, treat it as an attack indicator.
Uncaught TypeError: Failed to execute 'createPolicy' on 'TrustedTypePolicyFactory': Policy with name "app-ui" already exists.
The same policy name was created twice, usually because a bundle initialized on two entry points or a hot-reload re-ran the setup. Guard creation behind a module-level singleton, or add 'allow-duplicates' to the trusted-types directive if duplicate creation is genuinely unavoidable.
Sinks still fire with no violation reports in Firefox or Safari.
Those engines do not enforce Trusted Types natively. Without the Trusted Types polyfill loaded, window.trustedTypes is undefined, your feature-detect skips policy creation, and no enforcement or reporting happens. Load the polyfill (in enforcing or report-only build) before your application script. See the browser-support matrix in Enforcing require-trusted-types-for script.
eval or setTimeout('…') throws after enabling Trusted Types.
The string forms of eval, setTimeout, setInterval, and the Function constructor are script sinks and require a TrustedScript. Prefer refactoring to the function form (setTimeout(() => {…})), which is not a sink. If dynamic evaluation is unavoidable, wrap the source in a policy’s createScript and keep that policy tightly scoped.
Reports flood in from a browser extension or injected content script.
Extension content scripts run in the page’s Trusted Types context in some configurations and can generate violation noise you cannot fix. Filter reports by sourceFile — drop those originating from chrome-extension://, moz-extension://, or unknown origins — before alerting, so extension activity does not mask real first-party regressions.
Security Boundary
Permalink to "Security Boundary"Trusted Types enforce that strings reaching DOM injection sinks inside your JavaScript passed through a policy you authored. That is a DOM-XSS control and nothing more. It does not:
- Verify the integrity of externally fetched scripts or stylesheets. Trusted Types never inspect the bytes a
<script src>downloads — only that the URL string came from a policy. Byte-level verification is the job of Subresource Integrity; the two are complementary, and the join is shown in Step 3. For the fetch-integrity mechanics, see Browser Enforcement & Security Boundaries. - Stop a compromised CDN from serving a malicious bundle. A tampered file at an allow-listed origin passes
createScriptURLfine — its URL is legitimate. Only anintegrityhash catches the byte change. - Replace a CSP
script-srcallow-list. Trusted Types govern DOM sinks, not which origins may load scripts. Run both directives together. - Sanitize on your behalf. A policy whose
createHTMLreturns its input unchanged satisfies the type requirement while providing zero protection. The security lives in what your callback does, not in the type wrapper. - Protect non-supporting browsers without the polyfill. Native enforcement is Chromium-only today; Firefox and Safari need the polyfill or they get no protection at all.
For recovering gracefully when a fetch-layer control blocks a resource — a different failure class than a sink violation — see Handling SRI Failures with onerror Handlers.
Frequently asked questions
Permalink to "Frequently asked questions"Do Trusted Types replace the need for Subresource Integrity?
No. Trusted Types govern how strings reach dangerous DOM sinks inside the page’s own JavaScript — a DOM XSS control. Subresource Integrity verifies that the bytes of an externally fetched script or stylesheet match a cryptographic hash — a fetch-integrity control. They defend different layers and are designed to run together, as the createScriptURL plus integrity pattern in Step 3 shows.
Why do I see This document requires 'TrustedHTML' assignment?
The page has an enforcing require-trusted-types-for 'script' policy and some code assigned a plain string to a sink such as element.innerHTML. Route that assignment through a Trusted Types policy that returns a TrustedHTML object, or register a default policy to convert the string centrally. The violation report’s sourceFile and lineNumber name the exact caller.
Are Trusted Types supported in Firefox and Safari?
Native enforcement ships in Chromium-based browsers (Chrome, Edge, Opera). As of 2026 Firefox and Safari do not enforce Trusted Types natively, so you load the official W3C polyfill to get equivalent enforcement and to inventory sinks during migration. Without it, window.trustedTypes is undefined and no protection applies in those engines.
Does a default policy weaken Trusted Types?
A default policy is a migration and compatibility tool. It intercepts every otherwise-unrouted sink, which is convenient but centralizes risk: a permissive default policy that returns input unchanged reintroduces the DOM XSS you were trying to remove. Keep it narrow, sanitize inside it, throw on createScriptURL, and log every call so you can retire it.
Can I use DOMPurify as a Trusted Types policy?
Yes. DOMPurify has native Trusted Types support: calling DOMPurify.sanitize with RETURN_TRUSTED_TYPE: true returns a TrustedHTML object directly, so its output can be assigned to innerHTML under an enforcing policy without any further wrapping. Add its policy name dompurify to the trusted-types allow-list.
What is the difference between the trusted-types and require-trusted-types-for directives?
require-trusted-types-for 'script' turns enforcement on — it makes the DOM sinks reject plain strings. trusted-types is the allow-list of which policy names may be created. You typically deploy both: the first to enforce, the second to lock policy creation down to your audited names.
Related
Permalink to "Related"- Enforcing require-trusted-types-for script — directive syntax, browser-support matrix, polyfill wiring, and staged Report-Only-to-enforcing rollout
- Coordinating SRI, CSP & Trusted Types — combining fetch-integrity, origin allow-listing, and DOM-sink enforcement into one defense-in-depth policy
- Configuring Content Security Policy with SRI — the CSP header this workflow extends, including
require-sri-forand hash-basedscript-src - Browser Enforcement & Security Boundaries — the fetch-layer enforcement that verifies script bytes, complementing the DOM-layer control on this page