Writing a Trusted Types Policy with DOMPurify
Permalink to "Writing a Trusted Types Policy with DOMPurify"Part of Trusted Types & DOM XSS Prevention, this page walks through the policy module itself — the file that turns DOMPurify into the single sanitising choke point every DOM sink in your application has to pass through, plus the CSP that names it and the tests that prove it works.
Quick Reference
Permalink to "Quick Reference"| Item | Value |
|---|---|
| Factory | window.trustedTypes.createPolicy(name, rules) |
| Rule callbacks | createHTML, createScript, createScriptURL |
| Callback return type | Any value; it is converted to a string, then wrapped |
| Produced objects | TrustedHTML, TrustedScript, TrustedScriptURL |
| CSP allow-list | trusted-types <name> [<name>…] ['allow-duplicates'] |
| Enforcement switch | require-trusted-types-for 'script' |
| Reserved name | default — invoked automatically for unrouted strings |
| DOMPurify option | RETURN_TRUSTED_TYPE: true returns TrustedHTML instead of a string |
| DOMPurify’s own policy name | dompurify (must be allow-listed when the option is used) |
| Feature detect | window.trustedTypes && window.trustedTypes.createPolicy |
| Type check helper | window.trustedTypes.isHTML(value) |
| Violation directive | require-trusted-types-for for sinks, trusted-types for policy creation |
Browser support is still moving. Chromium has enforced Trusted Types for years; Firefox shipped the API more recently; WebKit has not enabled it as of this writing. Feature-detect rather than assume, and load the W3C polyfill if you need enforcement in every engine.
The mental model
Permalink to "The mental model"A Trusted Types policy is a named function table. createPolicy takes a name and an object with up to three callbacks, and returns a policy object whose methods produce opaque typed values. createHTML returns a TrustedHTML, createScriptURL returns a TrustedScriptURL, createScript returns a TrustedScript. The browser refuses to invent these objects on its own: with require-trusted-types-for 'script' active, the only way one comes into existence is through a callback you wrote. That is the whole security argument. Instead of auditing every one of the hundreds of places your codebase touches innerHTML, you audit three functions.
The callbacks are not required to sanitise. Nothing in the platform inspects what they return — a policy whose createHTML is the identity function is perfectly legal and perfectly useless. The value of the feature is that it makes the dangerous surface small enough to review, and DOMPurify is what you put inside that small surface to make it actually safe. DOMPurify parses the input in an inert document, walks the resulting tree, and drops elements and attributes that are not on its allow-list, returning markup that cannot execute. Pairing the two gives you enforcement (the browser will not accept anything else) plus a sanitiser that has been adversarially tested for years.
One detail catches almost everyone: the value your callback returns is stringified before the policy wraps it. createHTML is expected to hand back a string, and the policy — not DOMPurify — mints the TrustedHTML. That is why the sanitiser’s own trusted-type mode is redundant inside your policy and only useful outside it.
Canonical example: the policy module
Permalink to "Canonical example: the policy module"Put the whole thing in one module that nothing else in the application is allowed to duplicate. Importing it has the side effect of registering the policies, so it must be imported before any code that writes to a sink — usually the first import in your entry point.
// src/security/trusted-types.js
import DOMPurify from 'dompurify';
export const HTML_POLICY_NAME = 'app-html';
export const LOADER_POLICY_NAME = 'app-loader';
// Sanitiser configuration. RETURN_TRUSTED_TYPE stays off on purpose:
// the policy below does the wrapping, so a string is what we want back.
const SANITIZE_CONFIG = {
USE_PROFILES: { html: true },
ALLOWED_ATTR: ['href', 'title', 'target', 'rel', 'class', 'lang'],
FORBID_TAGS: ['style', 'form', 'input'],
ALLOW_DATA_ATTR: false,
};
// Script sources this application is ever allowed to load at runtime.
const SCRIPT_ORIGINS = new Set([
'https://www.example.com',
'https://cdn.example.com',
]);
const SCRIPT_PATH_PREFIXES = ['/static/js/', '/vendor/'];
function assertAllowedScriptUrl(input) {
const url = new URL(String(input), document.baseURI);
if (!SCRIPT_ORIGINS.has(url.origin)) {
throw new TypeError(`Blocked script origin: ${url.origin}`);
}
if (!SCRIPT_PATH_PREFIXES.some((prefix) => url.pathname.startsWith(prefix))) {
throw new TypeError(`Blocked script path: ${url.pathname}`);
}
return url.href;
}
const htmlRules = {
createHTML: (input) => DOMPurify.sanitize(String(input), SANITIZE_CONFIG),
};
const loaderRules = {
createScriptURL: assertAllowedScriptUrl,
};
// Where the platform has no Trusted Types, return an object with the same
// shape so every call site is written once and runs everywhere.
function definePolicy(name, rules) {
if (window.trustedTypes && window.trustedTypes.createPolicy) {
return window.trustedTypes.createPolicy(name, rules);
}
return rules;
}
export const htmlPolicy = definePolicy(HTML_POLICY_NAME, htmlRules);
export const loaderPolicy = definePolicy(LOADER_POLICY_NAME, loaderRules);
Two policies, not one. Markup sanitisation and script loading are different trust decisions with different failure modes, and splitting them means a bug in the HTML path can never mint a script URL. Note also that loaderRules has no createHTML and htmlRules has no createScriptURL: a missing callback means that policy simply cannot produce that type, which is the cheapest possible restriction.
The matching CSP names both policies and switches enforcement on:
Content-Security-Policy:
require-trusted-types-for 'script';
trusted-types app-html app-loader;
script-src 'self' https://cdn.example.com;
object-src 'none';
base-uri 'self';
report-to tt-violations
Reporting-Endpoints: tt-violations="https://www.example.com/_/csp-reports"
If the names in the directive and the names in createPolicy ever drift apart, createPolicy throws a TypeError and the page ends up with locked sinks and no way to unlock them. Exporting the names as constants and generating the header from the same source of truth removes the whole class of mistake. The header syntax itself is covered in Configuring Content Security Policy with SRI.
Before and after at the call site
Permalink to "Before and after at the call site"The migration is mechanical. Every assignment gains one function call:
// Before — throws once require-trusted-types-for 'script' is enforced
commentBody.innerHTML = comment.bodyHtml;
// After — the string is sanitised and typed on its way in
commentBody.innerHTML = htmlPolicy.createHTML(comment.bodyHtml);
The same shape applies to outerHTML, document.write, and DOMParser.parseFromString. For script loading, loaderPolicy.createScriptURL(src) produces the value you assign to script.src; combining that with an integrity attribute is covered in Adding Integrity to Runtime-Injected Scripts.
How RETURN_TRUSTED_TYPE interacts with your policy
Permalink to "How RETURN_TRUSTED_TYPE interacts with your policy"DOMPurify has native Trusted Types support. Pass RETURN_TRUSTED_TYPE: true and sanitize() returns a TrustedHTML instead of a string, produced by an internal policy DOMPurify registers under the name dompurify. That is genuinely useful — but only in the case where DOMPurify is your entire policy layer and you have no createPolicy call of your own.
Inside a createHTML callback the option is at best redundant and at worst confusing. Your callback’s return value is stringified and then re-wrapped by your policy, so the TrustedHTML DOMPurify built is unwrapped immediately. You pay for a second policy registration, you must add dompurify to the trusted-types directive or the library throws while creating it, and you gain nothing. Keep the option off inside a policy and on only when you are assigning the sanitiser’s output straight to a sink.
The sanitiser-only variant is a legitimate choice for a small application:
import DOMPurify from 'dompurify';
// Returns a TrustedHTML; no createPolicy call anywhere in the app.
commentBody.innerHTML = DOMPurify.sanitize(dirty, { RETURN_TRUSTED_TYPE: true });
Content-Security-Policy: require-trusted-types-for 'script'; trusted-types dompurify
The trade-off is that you no longer own the sanitiser configuration at a single point, and you cannot express different rules for different sinks. Once more than one part of the codebase writes markup, the module above is the better shape.
Variants
Permalink to "Variants"Routing by sink type
Permalink to "Routing by sink type"createHTML, createScript and createScriptURL are not interchangeable, and the correct treatment of each is different. Markup gets sanitised. URLs get allow-listed. Arbitrary code strings get refused: omit createScript entirely and eval stays permanently blocked, which is almost always what you want.
The default policy as a migration aid
Permalink to "The default policy as a migration aid"createPolicy('default', …) registers the policy the browser calls automatically whenever a bare string reaches a guarded sink. It exists so a large codebase, or a third-party widget you cannot edit, keeps working while you route call sites one at a time.
window.trustedTypes.createPolicy('default', {
createHTML: (input, _type, sink) => {
// sink is a string like "Element innerHTML" — log it, then fix the caller.
navigator.sendBeacon('/_/unrouted-sinks', JSON.stringify({ sink }));
return DOMPurify.sanitize(String(input), SANITIZE_CONFIG);
},
createScriptURL: (input, _type, sink) => {
throw new TypeError(`Unrouted script URL at ${sink}`);
},
});
Content-Security-Policy:
require-trusted-types-for 'script';
trusted-types app-html app-loader default
Two things make this a crutch rather than a solution. It is global, so every sink in the document — including ones inside injected third-party code — gets the same sanitiser configuration whether or not that is appropriate. And it removes the error that would have told you where the unrouted sink was, which is the single most valuable signal the feature produces. The beacon above exists to replace that signal: it turns a silent rescue into a work item. Delete the default policy when the beacon stops firing, and treat that deletion as the actual completion of the migration. A staged rollout that reaches this point is described in Rolling Out a Script Policy in Report-Only Mode.
Loading the sanitiser with an integrity attribute
Permalink to "Loading the sanitiser with an integrity attribute"If DOMPurify comes from a CDN rather than your bundle, hash it. A tampered sanitiser is a sanitiser that returns its input unchanged, and your policy would happily wrap the result in a TrustedHTML:
<script src="https://cdn.example.com/vendor/purify.min.js"
integrity="sha384-93Tfv7uV1HlQrPdygWlUVCZahwo7YmmrV5R1jxu+3p45mwfubiElT3LuAnqmochg"
crossorigin="anonymous"></script>
Regenerate the hash on every version bump; a stale hash blocks the script and the page then runs with no sanitiser at all unless your policy fails closed.
Gotchas and Edge Cases
Permalink to "Gotchas and Edge Cases"-
A missing
crossorigin="anonymous"blocks the sanitiser before any of this matters. A cross-origin<script>with anintegrityattribute but nocrossoriginis fetched in no-CORS mode, the response is opaque, the browser cannot hash it, and the script is discarded. DOMPurify never loads,createPolicythrows on the missing global, and every sink in the page is locked. The attribute is mandatory on every integrity-bearing tag, without exception. -
createPolicythrows if the name is not allow-listed — and it throws late. The failure surfaces at module evaluation, after the CSP has already locked the sinks. The page is then strictly worse off than with no policy at all. Verify the name pairing in a smoke test rather than trusting review. -
Calling
createPolicytwice with the same name is an error. Bundlers that include a module in two chunks, hot reload, and test harnesses all trip this. Keep the calls in one module with module-level singleton exports as shown above.'allow-duplicates'in thetrusted-typesdirective silences the error, but it also lets injected code re-register a name you rely on, so reach for it only when a build constraint leaves no alternative. -
A callback that returns
nullorundefinedstill produces a violation. IfDOMPurify.sanitizeis handed a value it cannot process, or yourcreateScriptURLreturns nothing on a path you forgot to cover, the assignment fails with the sameTypeErroras an unrouted sink. Make every branch either return a string or throw with a message that names the input. -
The policy does not verify what a script URL serves.
createScriptURLchecks a string; it says nothing about the bytes that come back from that URL. Pair the allow-list with anintegrityattribute on the injected element, as laid out in Deploying Defense-in-Depth Script Controls.
Verification Steps
Permalink to "Verification Steps"1. Confirm both policies registered
Permalink to "1. Confirm both policies registered"Open the console on the enforcing page and check the factory:
window.trustedTypes.isHTML(htmlPolicy.createHTML('<b>ok</b>'));
Expected output:
true
A false result means you are looking at the fallback object, not a real policy — the feature detect took the non-Trusted-Types branch.
2. Confirm the sanitiser is actually running
Permalink to "2. Confirm the sanitiser is actually running"htmlPolicy.createHTML('<img src=x onerror=alert(1)><b>hi</b>').toString();
Expected output — the event handler and the broken image source are gone, the safe markup survives:
<img src="x"><b>hi</b>
If the string comes back unchanged, DOMPurify is not loaded or your config is wrong; treat that as a release blocker.
3. Trigger and read a violation
Permalink to "3. Trigger and read a violation"document.querySelector('#panel').innerHTML = '<i>raw</i>';
Expected console error:
Uncaught TypeError: Failed to set the 'innerHTML' property on 'Element':
This document requires 'TrustedHTML' assignment.
The matching report delivered to your endpoint has roughly this body — the exact key casing varies by browser and Reporting API version:
{
"type": "csp-violation",
"url": "https://www.example.com/thread/1",
"body": {
"documentURL": "https://www.example.com/thread/1",
"effectiveDirective": "require-trusted-types-for",
"blockedURL": "trusted-types-sink",
"disposition": "enforce",
"sample": "Element innerHTML|<i>raw</i>",
"statusCode": 200
}
}
The sample field names the sink and carries a truncated prefix of the payload, which is usually enough to identify the call site. Collecting these at scale is covered in Collecting CSP Violation Reports with the Reporting API.
4. Confirm a disallowed script URL is refused
Permalink to "4. Confirm a disallowed script URL is refused"loaderPolicy.createScriptURL('https://attacker.example/evil.js');
Expected output:
Uncaught TypeError: Blocked script origin: https://attacker.example
A returned string here means the allow-list has a hole — most often a wildcard slipped into SCRIPT_ORIGINS, or a path prefix broad enough to cover a user-uploads directory.
Frequently Asked Questions
Permalink to "Frequently Asked Questions"Should my own policy call DOMPurify with RETURN_TRUSTED_TYPE?
No. Inside a createHTML callback the return value is converted to a string and then wrapped by your policy, so asking DOMPurify for a TrustedHTML makes it create a second policy whose name you also have to allow-list, for no benefit. Set RETURN_TRUSTED_TYPE only when you call DOMPurify.sanitize directly at a sink and want the result assignable without a policy of your own.
Is the default policy safe to leave in production?
It is safe in the sense that it still sanitises, but it defeats the point of the feature. A default policy silently rewrites every unrouted assignment, so the code that should have been fixed keeps working and you lose the errors that tell you where the remaining sinks are. Treat it as a temporary aid, log every call with its sink name, and delete it once the log goes quiet.
Why should createScriptURL not use a sanitiser?
A sanitiser removes dangerous constructs from markup; a script URL has none to remove. Any URL that passes the callback results in a full-privilege script running in your origin, so the only meaningful check is whether the exact origin and path are ones you intended to load. Parse the input with the URL constructor and compare against an explicit allow-list, then throw on anything else.
What happens in a browser that does not support Trusted Types?
window.trustedTypes is undefined, so an unguarded createPolicy call throws and takes the page down. Feature-detect and return a plain object exposing createHTML and createScriptURL with the same signatures. Call sites stay identical, sanitisation still runs, and only the type enforcement is missing — which is exactly what the platform is not providing there.
Where does the policy name have to match?
In two places: the first argument to createPolicy and the trusted-types directive in your Content-Security-Policy. If they disagree, createPolicy throws and the page loses sanitisation entirely while the sinks stay locked. Keep the name in one exported constant, and if DOMPurify creates its own policy remember that its name is dompurify and must be listed too.
Related
Permalink to "Related"- Enforcing require-trusted-types-for script — the directive that makes this policy mandatory, with its Report-Only and meta-tag delivery options
- Layering CSP Nonces, SRI and Trusted Types — how the policy fits alongside a nonce-based
script-srcand integrity hashes in one header - Migrating from unsafe-inline to Hash-Based CSP — the parallel migration for inline scripts, usually done in the same release train