Why a Good CSP Still Isn't the Whole Story
A strict Content Security Policy is one of the best defenses against cross-site scripting. Ban unsafe-inline, add a nonce or hash, layer on strict-dynamic, and an attacker can no longer inject a <script> tag that the browser will run. That stops the classic reflected and stored XSS attacks cold.
But there's a whole class of XSS that a normal CSP never sees: DOM-based XSS. Here the malicious data never touches your server-rendered HTML and never arrives as a new script tag. Instead, trusted JavaScript that already passed your CSP takes attacker-controlled input and feeds it into a dangerous DOM API. The browser executes it because your own script asked it to. The CSP is satisfied, and the attack still runs.
Trusted Types is the browser feature built specifically to close this gap, and it's delivered through the same CSP header you're already sending. This post explains what DOM XSS looks like, what Trusted Types actually does, and how to turn it on without breaking your app.
The Blind Spot: DOM-Based XSS
DOM XSS happens when data flows from an attacker-controllable source into a dangerous sink without being sanitized. A source is anything an attacker can influence: location.hash, location.search, document.referrer, a postMessage payload, or a field read back from an API. A sink is a DOM API that turns a string into live markup or code.
The most common sinks are:
element.innerHTMLandouterHTMLdocument.write()eval(),setTimeout(string), and theFunctionconstructorscript.src,script.text, andiframe.srcdocelement.insertAdjacentHTML()
Here's the canonical bug. It looks harmless and it ships to production constantly:
// Reads from the URL fragment and writes it straight into the page
const name = decodeURIComponent(location.hash.slice(1));
document.getElementById('greeting').innerHTML = 'Hello, ' + name;
Now an attacker sends a victim to https://yoursite.com/#<img src=x onerror=alert(document.cookie)>. The script that writes the greeting is your script — it's allowed by your CSP. But the string it assigns to innerHTML contains an event handler, and the browser runs it. No new script tag was injected, so a nonce-based CSP has nothing to catch. This is why sites with an A-grade CSP can still be vulnerable to XSS.
What Trusted Types Actually Does
Trusted Types flips the security model of these sinks. Normally a DOM sink accepts a plain string. With Trusted Types enforced, the sinks stop accepting strings entirely. Assigning a raw string to innerHTML throws a TypeError. To use a sink, you must pass a special typed object — a TrustedHTML, TrustedScript, or TrustedScriptURL — and the only way to create one is through a policy that you explicitly registered.
That single rule changes everything. Instead of auditing hundreds of scattered sink assignments across your codebase, you now have a small number of policies where dangerous values are created. Your sanitization logic lives in one auditable place, and any code that forgot to go through it simply crashes instead of silently creating a vulnerability. Trusted Types turns "did every developer remember to sanitize?" into a guarantee the browser enforces.
Turning It On Through CSP
Trusted Types is activated with two CSP directives. The first switches on enforcement:
Content-Security-Policy: require-trusted-types-for 'script';
With require-trusted-types-for 'script' present, every injection sink in the document now demands a Trusted Type. Pass a string and the browser blocks the assignment and reports a violation, exactly like any other CSP breach.
The second directive controls which policies are allowed to exist:
Content-Security-Policy: require-trusted-types-for 'script'; trusted-types dompurify default;
The trusted-types directive is an allow-list of policy names. Only trustedTypes.createPolicy('dompurify', …) and createPolicy('default', …) will succeed; any attempt to create a policy with a different name throws. This matters because an attacker who can run a little script might otherwise register their own permissive policy and neutralize the whole scheme. A few useful values:
trusted-types 'none'— no policies at all may be created (maximum lockdown, for apps that never touch these sinks).trusted-types myPolicy— only the named policy is allowed.trusted-types myPolicy 'allow-duplicates'— permit creating the same policy name more than once (handy when multiple bundles each initialize it).
Writing a Policy
A policy is a small object with one or more of three functions: createHTML, createScript, and createScriptURL. Each takes the untrusted input string and returns the sanitized string that will be wrapped in a Trusted Type. The realistic pattern is to delegate the actual cleaning to a battle-tested sanitizer:
import DOMPurify from 'dompurify';
const policy = trustedTypes.createPolicy('dompurify', {
createHTML: (input) => DOMPurify.sanitize(input),
});
// Now this is allowed, because the value is a TrustedHTML, not a string:
element.innerHTML = policy.createHTML(userProvidedMarkup);
DOMPurify has first-class Trusted Types support built in. If you pass { RETURN_TRUSTED_TYPE: true } it returns a TrustedHTML directly, so you don't even need to wrap the call yourself.
The default policy
There's a special policy named default. If a plain string reaches a sink and enforcement is on, the browser will call your default policy as a last resort, passing the offending string. This is the escape hatch for legacy code and third-party libraries you can't easily rewrite:
trustedTypes.createPolicy('default', {
createHTML: (input) => DOMPurify.sanitize(input),
});
Use it deliberately. A default policy that just returns its input unchanged (createHTML: (s) => s) technically satisfies the browser while giving you zero protection — it's the Trusted Types equivalent of unsafe-inline. Treat the default policy as a sanitization checkpoint, not a rubber stamp, and log what flows through it so you can find and fix the code that still depends on it.
Roll It Out in Report-Only Mode First
Flipping require-trusted-types-for on across an existing app will almost certainly break something — some component, analytics snippet, or ad tag is assigning strings to a sink right now. So don't enforce on day one. Ship it in Report-Only mode exactly like any other CSP change:
Content-Security-Policy-Report-Only: require-trusted-types-for 'script'; trusted-types dompurify default; report-to csp-endpoint
The browser evaluates the rule against real production traffic, sends you a violation report for every string-to-sink assignment it would have blocked, and breaks nothing. Each report points at the exact sink and script location, giving you a precise punch-list of the code paths to migrate. Work through them — route each one through a policy or refactor it to stop using the sink — and when the reports dry up, switch the header from Report-Only to enforcing. This is the same safe workflow we cover in our report-only rollout guide, applied to Trusted Types.
Framework and Browser Support
You rarely have to wire up sinks by hand, because modern frameworks already funnel DOM writes through their own sanitizers:
- Angular supports Trusted Types natively and has for several major versions — its built-in sanitizer produces Trusted Types when the policy is present.
- React avoids raw
innerHTMLfor normal rendering; the main thing to guard isdangerouslySetInnerHTML, which you should feed from a DOMPurify policy. - DOMPurify is the de-facto sanitizer and integrates directly with the API via
RETURN_TRUSTED_TYPE.
On the browser side, Chromium-based browsers (Chrome and Edge) enforce Trusted Types today. Firefox and Safari don't fully enforce it yet, which is exactly why Trusted Types is a defense-in-depth layer: adding the directives costs nothing on browsers that ignore them, and on the browsers that do enforce, it eliminates DOM XSS outright. There's no downside to shipping it now.
Common Pitfalls
- A pass-through default policy. As noted above, returning the input untouched defeats the purpose. Always sanitize inside the policy.
- Forgetting the
trusted-typesallow-list. Enforcingrequire-trusted-types-forwithout naming allowed policies lets any code create any policy. Pin the names. - Third-party scripts. An ad or analytics vendor that writes to
document.writewill trip enforcement. Catch these in Report-Only first and decide whether to sandbox them in an iframe or route them through the default policy. - Treating it as a CSP replacement. Trusted Types stops DOM XSS; it does nothing about injected inline scripts. You still need a nonce or hash based script-src. The two are complementary halves of a complete anti-XSS policy.
Where It Fits in Your Header Strategy
Think of XSS defense as two locks on the same door. A strict script-src with nonces and strict-dynamic stops the browser from running scripts an attacker injected into your markup. Trusted Types stops your own trusted scripts from being tricked into building malicious markup at runtime. Reflected and stored XSS need the first lock; DOM XSS needs the second. Ship both and you've closed the door on the entire category.
If you're not sure which sinks your app touches or whether your current policy would even notice a DOM-based attack, start in Report-Only and let the violation reports draw you a map. Then scan your site to confirm the headers land the way you expect before you flip enforcement on.