DOM-based XSS is the cockroach of web security. Reflected and stored XSS? Server-side sanitization and output encoding largely killed those a decade ago. DOM XSS? Still very much alive, quietly lurking in every innerHTML assignment and eval() call across your codebase.
The reason is structural: DOM XSS happens entirely in the browser, in JavaScript you wrote (or more often, JavaScript a dependency wrote). Your WAF can’t see it. Your templating engine can’t help. Static analysis tools flag it inconsistently. And every junior dev who joins the team is one element.innerHTML = userInput away from a vulnerability.
Trusted Types is the browser-native solution. It turns DOM XSS from a runtime vulnerability into a policy enforcement error — the browser itself becomes the last line of defense, refusing to let dangerous values reach dangerous sinks unless they were explicitly processed by a policy you define. It’s been in Chrome/Edge since 2020, Firefox shipped it in 2024, and the W3C spec is stable. There’s no good reason not to use it.
This article walks through a full deployment: from understanding the model, through report-only migration, to a hardened production configuration. Including the parts that bite you.
The W3C spec and reference polyfill live at https://github.com/w3c/trusted-types.
The Problem Trusted Types Actually Solves
Before touching any API, you need to understand what a "sink" is in this context.
A sink is any JavaScript API that takes a string and interprets it as HTML, JavaScript, or a URL that will execute code. The classic examples:
element.innerHTML = taintedString; // TrustedHTML sink
element.outerHTML = taintedString; // TrustedHTML sink
document.write(taintedString); // TrustedHTML sink
element.insertAdjacentHTML('beforeend', s); // TrustedHTML sink
eval(taintedString); // TrustedScript sink
new Function(taintedString); // TrustedScript sink
setTimeout(taintedString, 0); // TrustedScript sink (string form)
scriptElement.src = taintedURL; // TrustedScriptURL sink
new Worker(taintedURL); // TrustedScriptURL sink
The core issue: all of these accept plain strings. There’s nothing stopping someVar from being attacker-controlled. Trusted Types breaks this by making the browser reject plain strings at these sinks — you must pass a typed object (TrustedHTML, TrustedScript, or TrustedScriptURL) that can only be created through a registered policy function.
That policy function is where your sanitization lives. One place, auditable, enforced by the browser engine.
Step 1: Enable Trusted Types in Report-Only Mode
Never flip on enforcement mode on a production app without going through report-only first. You will break things. The question is whether you discover it in staging or in front of users.
Set this HTTP response header:
Content-Security-Policy-Report-Only: require-trusted-types-for 'script'; report-uri /csp-violations
Or if you’re already running a CSP, add to it:
Content-Security-Policy-Report-Only: default-src 'self'; require-trusted-types-for 'script'; report-uri /csp-violations
For Nginx:
add_header Content-Security-Policy-Report-Only \
"require-trusted-types-for 'script'; report-uri /csp-violations" always;
For Node/Express:
app.use((req, res, next) => {
res.setHeader(
'Content-Security-Policy-Report-Only',
"require-trusted-types-for 'script'; report-uri /csp-violations"
);
next();
});
Now navigate your app and watch the violation reports roll in. Each one looks like:
{
"csp-report": {
"document-uri": "https://yourapp.com/dashboard",
"violated-directive": "require-trusted-types-for",
"source-file": "https://yourapp.com/static/js/main.chunk.js",
"line-number": 847,
"column-number": 22
}
}
Build a simple endpoint to collect these, or pipe them into your existing logging infrastructure. The source-file + line-number is your hit list.
Gotcha: Third-party scripts run in your page context and their violations show up here too. Tag them early — you’ll handle them differently than your own code.
Step 2: Create Your Policies
A Trusted Types policy is a named factory that converts plain strings into typed objects. You register it once, use it everywhere.
// policies/html.js
import DOMPurify from 'dompurify';
const htmlPolicy = trustedTypes.createPolicy('html', {
createHTML: (input) => DOMPurify.sanitize(input, {
RETURN_TRUSTED_TYPE: false,
USE_PROFILES: { html: true }
})
});
export default htmlPolicy;
Usage throughout your codebase:
import htmlPolicy from './policies/html.js';
// Before Trusted Types — dangerous:
container.innerHTML = userGeneratedContent;
// After — safe:
container.innerHTML = htmlPolicy.createHTML(userGeneratedContent);
The createHTML method in your policy receives the raw string. Whatever you return from it becomes the value wrapped in a TrustedHTML object. The browser won’t accept the raw string at innerHTML anymore — only the TrustedHTML object passes.
Policy for Script URLs
For dynamic script loading (lazy imports, worker creation):
const scriptUrlPolicy = trustedTypes.createPolicy('script-url', {
createScriptURL: (url) => {
const parsed = new URL(url, location.origin);
// Only allow same-origin and explicitly allowlisted CDNs
const allowed = [
location.origin,
'https://cdn.yourcompany.com'
];
if (!allowed.some(origin => parsed.href.startsWith(origin))) {
throw new Error(`Untrusted script URL: ${url}`);
}
return url;
}
});
// Usage:
const worker = new Worker(scriptUrlPolicy.createScriptURL('/workers/processor.js'));
Policy for Inline Scripts (use sparingly)
const scriptPolicy = trustedTypes.createPolicy('inline-script', {
createScript: (code) => {
// You almost never want this. If you find yourself here,
// strongly consider restructuring the code instead.
console.warn('TrustedScript policy invoked — review this call site');
return code;
}
});
Gotcha: createScript for TrustedScript exists, but using it is usually a design smell. eval() and new Function() with user-controlled input are the problem — if your policy just passes through the string, you’ve created an audit chokepoint at best, but no actual security. Make sure the input to createScript policies is never user-controlled.
Step 3: Handle DOMPurify Correctly
DOMPurify 3.x has native Trusted Types support. This is the cleanest integration path for any app that does rich HTML rendering:
import DOMPurify from 'dompurify';
// Tell DOMPurify to return TrustedHTML objects directly
const purifyPolicy = trustedTypes.createPolicy('dompurify', {
createHTML: (dirty) => DOMPurify.sanitize(dirty, { RETURN_TRUSTED_TYPE: true })
});
// Now this works with enforcement enabled:
element.innerHTML = purifyPolicy.createHTML(untrustedHTML);
Install with:
npm install dompurify
# TypeScript types:
npm install -D @types/dompurify
The RETURN_TRUSTED_TYPE: true option tells DOMPurify to create a TrustedHTML object itself, using an internal policy. But there’s a subtlety: if you’re using DOMPurify in an environment without Trusted Types support (older browsers, Node.js for SSR), RETURN_TRUSTED_TYPE falls back gracefully to returning a plain string. Test your SSR path explicitly.
Step 4: The Default Policy — Your Migration Escape Hatch
Here’s the feature that makes large-scale migration actually feasible: the default policy.
If you register a policy named exactly "default", the browser invokes it automatically whenever a plain string hits a Trusted Types sink. This gives you a monitoring hook and an opt-in escape hatch during migration:
trustedTypes.createPolicy('default', {
createHTML: (input, _, sink) => {
// Log every violation during migration
console.error('Trusted Types violation at', sink, ':', input.substring(0, 200));
reportViolation({ input, sink, stack: new Error().stack });
// Return the value to avoid breaking the app during migration phase
// DELETE this return statement when enforcement is complete
return input;
},
createScriptURL: (input) => {
console.error('Untrusted script URL:', input);
return input;
}
});
This is your bridge. During migration, the default policy catches everything that slips through, logs it, and lets the app continue running. Once you’ve fixed every violation source, remove the default policy and switch to enforcement mode. If anything breaks, you missed a call site.
Gotcha: The default policy is powerful and dangerous. Don’t ship it as a permanent fixture — it’s a migration tool, not a production configuration. A default policy that just passes strings through defeats the entire purpose of Trusted Types.
Step 5: Dealing with Frameworks
React
React 18+ uses Trusted Types internally for its DOM operations. For the most part, if you’re using JSX and not dangerouslySetInnerHTML, you’re already safe.
When you do need dangerouslySetInnerHTML, wrap it:
import htmlPolicy from './policies/html.js';
function RichContent({ html }) {
return (
<div
dangerouslySetInnerHTML={{
__html: htmlPolicy.createHTML(html)
}}
/>
);
}
React accepts TrustedHTML objects in dangerouslySetInnerHTML.__html as of React 18.3+. If you’re on an older version, the toString() of a TrustedHTML object returns the string value, which React uses — but check your React version before relying on this.
Angular
Angular 15+ has built-in Trusted Types support via the @angular/core package. Set your policy name in bootstrapApplication:
// main.ts
import { bootstrapApplication } from '@angular/platform-browser';
import { AppComponent } from './app/app.component';
bootstrapApplication(AppComponent, {
providers: [
{
provide: 'TRUSTED_TYPES_POLICY',
useValue: trustedTypes.createPolicy('angular', {
createHTML: (s) => s, // Angular sanitizes internally
createScriptURL: (s) => s
})
}
]
});
In practice, Angular’s DomSanitizer already handles sanitization — you’re mostly telling the browser to trust Angular’s output. Review Angular’s own Trusted Types guide since the integration details change across major versions.
Vue 3
Vue 3 doesn’t have first-class Trusted Types support as of this writing. You’ll need the default policy during migration and manual handling anywhere v-html is used:
<template>
<!-- Dangerous — avoid v-html with user content -->
<div v-html="sanitizedContent"></div>
</template>
<script setup>
import { computed } from 'vue';
import htmlPolicy from '../policies/html.js';
const props = defineProps(['rawHtml']);
const sanitizedContent = computed(() =>
htmlPolicy.createHTML(props.rawHtml).toString()
);
</script>
The .toString() call here is the friction point — Vue’s v-html expects a string, not a TrustedHTML object. Track Vue’s Trusted Types issue for official support.
Step 6: Third-Party Libraries
This is where most teams get stuck. You can’t audit every npm package, and many popular libraries use innerHTML internally.
Your options, in order of preference:
-
Check if the library supports Trusted Types — many do now (DOMPurify, Lit, Workbox). Update and configure accordingly.
-
Wrap the library — intercept the library’s DOM writes through a thin wrapper that applies your policy before the library touches the DOM.
-
Use the default policy with allowlisting — in the default policy, check the call stack or the input content to decide whether to allow it:
trustedTypes.createPolicy('default', {
createHTML: (input, _, sink) => {
const stack = new Error().stack || '';
// Allow known-safe library paths
const allowedPatterns = [
'node_modules/some-charting-library',
'node_modules/some-editor-library'
];
const isAllowed = allowedPatterns.some(p => stack.includes(p));
if (!isAllowed) {
reportViolation({ input, sink, stack });
}
return input; // During migration only
}
});
- Sandbox untrusted widgets in iframes — for truly unmaintainable third-party code that you can’t fork or wrap, serve it from a separate subdomain in an iframe. It won’t share your page’s Trusted Types policy.
Gotcha: Stack-based allowlisting in the default policy is fragile — minification changes stack frame strings, and it’s bypassable with enough effort. Use it as a temporary migration aid, not a long-term strategy.
Step 7: Flip to Enforcement
Once your violation reports go quiet (or near-zero, with only known third-party exceptions), switch from Content-Security-Policy-Report-Only to Content-Security-Policy:
Content-Security-Policy: require-trusted-types-for 'script'; report-uri /csp-violations
Keep report-uri — violations in enforcement mode are still reported before the operation is blocked. You want that visibility.
If you need to allowlist specific policy names (prevent unauthorized policy creation):
Content-Security-Policy: require-trusted-types-for 'script'; trusted-types html dompurify script-url; report-uri /csp-violations
The trusted-types directive lists exactly which policy names are allowed. Any attempt to call trustedTypes.createPolicy('rogue-policy', ...) will throw. This is important: without this directive, any script on your page (including injected third-party scripts) can create policies.
Production-ready full CSP header:
Content-Security-Policy:
default-src 'self';
script-src 'self' 'nonce-{RANDOM}';
style-src 'self' 'unsafe-inline';
img-src 'self' data: https://cdn.yourcompany.com;
connect-src 'self' https://api.yourcompany.com;
require-trusted-types-for 'script';
trusted-types html dompurify script-url;
report-uri /csp-violations
Generate a fresh nonce per request server-side. The nonce approach for script-src pairs cleanly with Trusted Types — nonces control which scripts load, Trusted Types controls what those scripts do with the DOM.
Violations Endpoint
A simple Node.js endpoint to capture and log reports:
// routes/csp-violations.js
import express from 'express';
const router = express.Router();
router.post('/', express.json({ type: 'application/csp-report' }), (req, res) => {
const report = req.body?.['csp-report'];
if (!report) return res.status(400).end();
// Structured logging — pipe to your SIEM, Datadog, whatever
console.warn({
level: 'warn',
event: 'csp_violation',
directive: report['violated-directive'],
source: report['source-file'],
line: report['line-number'],
uri: report['document-uri'],
sample: report['script-sample']
});
res.status(204).end();
});
export default router;
In production, route these to something queryable — Elasticsearch, Loki, CloudWatch Logs. You want to alert on new violation types appearing after a deployment.
Gotchas Roundup
SSR environments: Node.js doesn’t have a trustedTypes global. Guard every policy creation:
const htmlPolicy = typeof trustedTypes !== 'undefined'
? trustedTypes.createPolicy('html', { createHTML: sanitize })
: { createHTML: sanitize }; // Fallback: still sanitizes, no type wrapping
Safari: As of early 2025, Safari has partial support. The require-trusted-types-for directive is ignored (doesn’t enforce), but it doesn’t throw errors either. You get the sanitization benefit from your policy functions without browser-level enforcement. Monitor caniuse.com/trusted-types for updates.
eval() in bundlers: Webpack and some older build tools use eval() for source maps in development mode. Switch to cheap-module-source-map or source-map in your webpack config when testing Trusted Types — eval-based source maps will trigger violations.
Policy name collisions: If two scripts call trustedTypes.createPolicy('html', ...) with the same name, the second call throws. In modular apps, be explicit about policy names and create them in a single module.
innerHTML in tests: Jest runs in jsdom, which may not support Trusted Types. Use the polyfill in your test setup, or your tests will silently skip enforcement. Add @nicolo-ribaudo/trusted-types-csp-violations-polyfill or the official trusted-types npm package to your test environment.
The Polyfill for Older Browsers
For browsers without native support:
npm install trusted-types
// At the very top of your entry point, before any other imports
import 'trusted-types';
The polyfill patches trustedTypes onto the global scope and intercepts dangerous DOM sinks. It’s not as ironclad as native browser enforcement (polyfills can be bypassed by code that runs before them), but it closes the gap for Firefox users before full native rollout and maintains consistent behavior across environments.
What You Actually Gained
After a full Trusted Types deployment, here’s what changed:
Every DOM XSS sink in your application is now covered by a policy function you wrote and can audit. New developers can’t accidentally introduce DOM XSS — the browser will reject their innerHTML = userInput at runtime and your violation reporting will catch it before it reaches production. Third-party scripts can’t create arbitrary policies without being named in your CSP. And you have a clear paper trail of every place in your codebase that handles potentially dangerous content.
This doesn’t replace other XSS mitigations — keep your output encoding, keep your CSP script-src, keep your sanitization. Trusted Types is a final enforcement layer, not a reason to get sloppy elsewhere. But it’s the first browser mechanism that structurally prevents an entire class of DOM XSS rather than just making it harder. That’s worth the migration effort.