Closing the DOM XSS Gap with CSP Trusted Types XSS Defenses
Cross-site scripting remains the most persistent class of web vulnerability, and the DOM-based variety is the hardest to kill because it never touches the server. The combination at the center of this guide — CSP Trusted Types XSS prevention — attacks the problem at its root by refusing to let untrusted strings reach the dangerous functions that turn data into executable code.
To be clear, this is a defense-in-depth story, not a single switch. Content Security Policy Level 3 hardens which scripts may run at all, while Trusted Types hardens what may be passed into injection sinks like innerHTML. Together they convert a whole category of “stringly-typed” mistakes into loud, blockable, reportable errors.
Why Allowlist CSP Fails in Practice
The original CSP model asked you to enumerate every trusted host: script-src 'self' cdn.example.com analytics.example.com. In theory this blocks injected scripts. In practice, research from Google that surveyed thousands of real policies found the overwhelming majority were trivially bypassable — usually because some allowlisted domain hosted a JSONP endpoint or an outdated library that an attacker could weaponize.
Consequently, the modern recommendation inverts the approach. Instead of trusting hosts, you trust individual script elements via a per-response nonce, and you let those trusted scripts transitively load their dependencies with strict-dynamic. This is the strict CSP pattern, and it scales far better than maintaining a brittle host list.
<!-- Each page load generates a fresh, unguessable nonce on the server.
Only script tags carrying THIS nonce are allowed to execute. -->
<script nonce="r4nd0m-per-request-value">
// Your trusted bootstrap code runs because the nonce matches.
import('/js/app.js');
</script>
<!-- An injected script has no way to know the nonce, so it is blocked. -->
<script>/* attacker payload — refused by the browser */</script>
// The matching response header (Express-style example).
// 'strict-dynamic' lets nonced scripts load further scripts they trust,
// while ignoring the host allowlist for backward compatibility.
app.use((req, res, next) => {
const nonce = crypto.randomBytes(16).toString('base64');
res.locals.nonce = nonce;
res.setHeader('Content-Security-Policy', [
`script-src 'nonce-${nonce}' 'strict-dynamic' https: 'unsafe-inline'`,
"object-src 'none'",
"base-uri 'none'",
"require-trusted-types-for 'script'",
].join('; '));
next();
});
Note the 'unsafe-inline' and https: tokens: modern browsers ignore them when a nonce and strict-dynamic are present, but older browsers fall back to them, which keeps the policy from breaking legacy clients. That layering is intentional backward compatibility, not a mistake.
How Trusted Types Locks Down Dangerous Sinks
CSP controls which scripts load, yet it cannot see a perfectly legitimate script writing attacker-controlled markup into element.innerHTML. That gap is precisely where DOM-based XSS lives. Trusted Types closes it by making the dangerous DOM sinks refuse plain strings entirely once require-trusted-types-for 'script' is active.
After that directive is enforced, assigning a string to innerHTML, outerHTML, a <script>.src, or calling eval() throws a TypeError. The only values these sinks accept are TrustedHTML, TrustedScript, or TrustedScriptURL objects, and those can only be minted by a policy you explicitly created. The W3C Trusted Types specification defines the full sink list.
Writing a Default Policy and Named Policies
Practically, you create policies through trustedTypes.createPolicy(). A named policy is the clean choice for new code: you call it explicitly wherever you genuinely need to inject HTML. A default policy, by contrast, runs automatically for any sink assignment that lacks a typed value, which is invaluable for taming legacy code you cannot rewrite immediately.
// A NAMED policy: call it explicitly at trusted injection points.
// Pair it with a real sanitizer such as DOMPurify.
const safeHtml = trustedTypes.createPolicy('app-html', {
createHTML: (input) => DOMPurify.sanitize(input, { RETURN_TRUSTED_TYPE: false }),
});
// Usage is explicit, so it is auditable in code review.
container.innerHTML = safeHtml.createHTML(userProvidedMarkup);
// A DEFAULT policy: a safety net for sinks you have not migrated yet.
// It receives the offending string plus the sink name for logging.
trustedTypes.createPolicy('default', {
createHTML: (input, _type, sink) => {
console.warn(`Default policy sanitized input for ${sink}`);
return DOMPurify.sanitize(input);
},
createScriptURL: (input) => {
const url = new URL(input, location.origin);
if (url.origin !== location.origin) {
throw new TypeError(`Blocked cross-origin script URL: ${url}`);
}
return url.toString();
},
});
Importantly, treat the default policy as a temporary migration aid rather than a permanent crutch. Because it fires invisibly, it can mask the very sinks you want to fix; the goal is to drive its invocations toward zero and then remove it. Strong identity controls such as passkeys and WebAuthn complement this work by shrinking what a successful XSS could even steal.
Report-Only Rollout and CSP Reporting
You should never flip enforcement on blind. Both CSP and Trusted Types support a report-only mode that logs violations without blocking anything, which lets you measure real breakage against real traffic first. Ship the report-only header, watch the reports flow in for a week or two, fix what surfaces, and only then enforce.
// Report-only: nothing breaks, but every violation is sent to your endpoint.
// Run this in production to discover sinks before you enforce.
const reportingPolicy = [
"script-src 'nonce-PLACEHOLDER' 'strict-dynamic'",
"require-trusted-types-for 'script'",
"trusted-types app-html default",
'report-uri /csp-violation-report',
'report-to csp-endpoint',
].join('; ');
res.setHeader('Content-Security-Policy-Report-Only', reportingPolicy);
// Receive the structured reports the browser POSTs on each violation.
app.post('/csp-violation-report', express.json({ type: '*/*' }), (req, res) => {
const report = req.body['csp-report'] ?? req.body;
// 'blocked-uri', 'sample', and 'effective-directive' tell you the offending sink.
logger.warn('csp_violation', report);
res.status(204).end();
});
The reports are gold during migration. Each one names the violated directive and often a short sample of the blocked content, so you can walk the list from most to least frequent and retire violations systematically. This is the same evidence-driven discipline behind effective secret-scanning rollouts: measure first, enforce second.
Migrating a Legacy Application
Migrating an older codebase follows a predictable arc. First, deploy report-only and catalog every violation. Next, replace ad-hoc innerHTML assignments with calls to a named policy backed by DOMPurify, so untrusted markup is sanitized rather than trusted blindly. Then route the unavoidable stragglers through a logging default policy and chip away at them.
Throughout the migration, resist the urge to write a permissive policy whose createHTML simply returns its input unchanged. That technically satisfies the type system while reintroducing the exact vulnerability you set out to fix — it is security theater. A genuine sanitizer is what gives Trusted Types its teeth. For services that talk to each other, pair this with transport hardening like mTLS and certificate pinning.
Browser Support and Fallback
As of 2026, Trusted Types and the relevant CSP Level 3 directives are supported in Chromium-based browsers, with other engines at varying stages of adoption. Crucially, the failure mode is safe: a browser that does not understand require-trusted-types-for simply ignores the directive, so your application keeps working exactly as before — you lose the extra protection on that browser but break nothing.
Therefore Trusted Types is a strict enhancement: it raises the floor where supported and is invisible where it is not. Server-side input validation, contextual output encoding, and a strict nonce-based CSP remain essential everywhere, because they protect users on every engine. Reference material at content-security-policy.com and the W3C spec is worth bookmarking as you tune directives.
When Not to Reach for This
Honestly, Trusted Types is not free engineering effort. A small static marketing site that never touches innerHTML with dynamic data gains little, and forcing it through a framework that already escapes everything can add friction without proportional benefit. Similarly, if your application is buried in third-party scripts that assign raw HTML, you will spend real time wrapping them before enforcement is viable.
In conclusion, the CSP Trusted Types XSS strategy is the most durable defense available against DOM-based injection, precisely because it moves the guarantee from convention to platform enforcement. Adopt a strict nonce CSP with strict-dynamic, layer Trusted Types on top, roll out in report-only mode, sanitize with a real library inside your policies, and migrate iteratively. Do that, and an entire class of vulnerability stops being something you hope you caught and becomes something the browser refuses to allow.