Enforcing Trusted Types in Extension Pages

Add Trusted Types to MV3 popups, options pages and side panels: the require-trusted-types-for CSP directive, a single sanitising policy, fixing violations, framework compatibility and report-only rollout.

Published October 2, 2026 Updated October 2, 2026 7 min read
Table of Contents

Code review catches most innerHTML with untrusted data, but not all — especially in dependencies, in code paths nobody tests, and in the refactor six months from now. Trusted Types turns the rule “never put an unsanitised string into an HTML sink” into something the browser enforces: with the right CSP directive, assigning a plain string to innerHTML, outerHTML, insertAdjacentHTML, document.write, script src and similar sinks throws a TypeError, and only values created by a policy you define are accepted. For extension pages, which run with all of the extension’s privileges, that is a valuable second line of defence. This guide rolls it out without breaking the extension. It belongs to extension security and CSP hardening.

How Trusted Types works

Trusted Types adds typed wrappers — TrustedHTML, TrustedScript, TrustedScriptURL — and policies that create them. When a page’s CSP contains require-trusted-types-for 'script', the browser rejects plain strings at injection sinks: el.innerHTML = str throws, while el.innerHTML = policy.createHTML(str) works, because the policy’s createHTML function ran (and presumably sanitised) the string. The trusted-types directive lists which policy names may be created, so a dependency cannot quietly create its own permissive policy. Safe APIs — textContent, setAttribute for non-URL attributes, createElement — are unaffected, which is why code that already follows good habits needs few changes.

A string's path to an HTML sink under Trusted TypesA plain string assigned to innerHTML is rejected with a TypeError; the same string passed through the named sanitising policy becomes TrustedHTML and is accepted; textContent accepts strings as before.Untrusted stringfrom storage / pageinnerHTML = strplain stringTypeErrorblockedthrough the policy insteadpolicy.createHTMLDOMPurify insideTrustedHTMLtyped valueinnerHTML = trustedaccepted
Only policy output reaches HTML sinks — the browser enforces it.

Step-by-step: rolling out Trusted Types

1. Start in report-only mode

1<!-- options.html, during rollout -->
2<meta http-equiv="Content-Security-Policy-Report-Only"
3      content="require-trusted-types-for 'script'; trusted-types readable-sanitizer">

Execution context: an extension page. Report-only mode logs a violation to the console for every plain string reaching a sink, without blocking anything. Use the browser’s console (and, if you like, a securitypolicyviolation event listener that records violations in development) to collect the list of sinks to fix. A <meta> policy affects only the page it is in, which makes per-page rollout easy; the manifest’s extension_pages CSP applies to all pages at once.

2. Create one sanitising policy

1// shared/trusted.js
2import DOMPurify from "dompurify";
3
4export const policy = globalThis.trustedTypes?.createPolicy("readable-sanitizer", {
5  createHTML: (input) => DOMPurify.sanitize(input, { RETURN_TRUSTED_TYPE: false }),
6}) ?? { createHTML: (input) => DOMPurify.sanitize(input) };   // engines without Trusted Types

Execution context: a module imported by every extension page. One policy, with a fixed name listed in the CSP, is the only way to produce TrustedHTML in your pages. Because it sanitises, using it is always safe. The fallback object keeps the same calling code working in engines that lack Trusted Types (Firefox, Safari) — there, sanitisation still happens, it just is not enforced by the browser. DOMPurify can also return TrustedHTML itself when configured with RETURN_TRUSTED_TYPE: true.

Common sinks and their Trusted Types fixinnerHTML with static templates, innerHTML with data, insertAdjacentHTML, script src assignment and framework raw-HTML features, with the change needed under Trusted Types.CodeUnder enforcementFixinnerHTML = '<b>Static</b>'ThrowscreateElement / policyinnerHTML = dataThrowstextContentinnerHTML = richHtmlThrowspolicy.createHTMLscript.src = urlThrowsAvoid; bundlev-html / dangerouslySetInnerHTMLThrowsPass TrustedHTML
Most fixes are switching to text APIs; the rest go through the one policy.

3. Fix each reported sink

 1// Before
 2list.innerHTML = items.map((i) => `<li>${i.title}</li>`).join("");
 3
 4// After — no HTML parsing at all
 5list.replaceChildren(...items.map((i) => Object.assign(document.createElement("li"), { textContent: i.title })));
 6
 7// Before — rich excerpt
 8excerpt.innerHTML = item.excerptHtml;
 9// After
10excerpt.innerHTML = policy.createHTML(item.excerptHtml);

Execution context: extension pages. Most violations disappear by switching to DOM construction and textContent, which is safer than any sanitiser. Route only genuinely rich content through the policy. Each fix removes a potential XSS sink regardless of whether Trusted Types ends up enforced.

4. Make frameworks cooperate

1// React: pass TrustedHTML through dangerouslySetInnerHTML
2<div dangerouslySetInnerHTML={{ __html: policy.createHTML(excerpt) }} />

Execution context: an extension page built with a framework. Modern React, Vue and Angular builds avoid string-based sinks in their own rendering paths, and their raw-HTML features accept TrustedHTML values. Some older libraries and component kits assign strings to innerHTML internally; report-only mode surfaces them. Replace them, configure them to use a policy if they support it, or — as a last resort — allow a narrowly named second policy for that library in the trusted-types directive.

From report-only to enforcementReport-only mode logs violations during normal use; each sink is fixed with text APIs or the sanitising policy; when a full test run reports zero violations, the directive moves into the enforced manifest CSP.Extension pagesConsole / testsManifest CSPreport-only violationsfix sinks (textContent / policy)full test run → 0 violationsmove directive to extension_pages
Collect, fix, re-run until silent, then enforce.

5. Enforce through the manifest

1{
2  "content_security_policy": {
3    "extension_pages": "script-src 'self'; object-src 'self'; base-uri 'none'; require-trusted-types-for 'script'; trusted-types readable-sanitizer"
4  }
5}

Execution context: the manifest. Once report-only runs are clean across all pages and tests, move the directives into the enforced policy for every extension page. Chrome applies it to the popup, options page, side panel, offscreen documents and any other extension page. The service worker has no DOM sinks, so the directive is irrelevant there. Engines that do not implement Trusted Types ignore the directives harmlessly.

6. Catch regressions in tests

1// e2e: fail the test on any Trusted Types violation
2page.on("console", (m) => { if (/TrustedHTML|Trusted Type/.test(m.text())) throw new Error(m.text()); });

Execution context: an end-to-end test against extension pages. With enforcement on, a new unsanitised sink throws at runtime — but only on the code path that reaches it. Running the full UI test suite with a console listener turns those runtime errors into test failures before release.

7. Keep content scripts in mind separately

Trusted Types enforcement through the extension CSP applies to extension pages, not to content scripts, which run under the page’s own policy in the isolated world. Content-script UI should follow the same habits — text APIs and a sanitiser — and, where it renders into a shadow root you control, you can still use a Trusted Types policy object for consistency if the engine supports it.

Common mistakes

  • Enforcing before collecting violations. Pages break in ways users find first.
  • Many permissive policies. A policy that returns its input unchanged defeats the purpose.
  • Policy names not listed in trusted-types. Creating them throws.
  • Assuming Firefox and Safari enforce it. They do not yet; keep sanitising regardless.
  • Fixing violations with policy.createHTML everywhere. Prefer text APIs; reserve the policy for rich content.

Cross-browser variation

  • Chrome / Edge: full Trusted Types support; the manifest CSP directives are enforced on extension pages.
  • Firefox: Trusted Types support has been in development; the directives are ignored where unsupported, so the fallback policy keeps behaviour consistent.
  • Safari: support has been limited; treat Trusted Types as Chrome-side enforcement of a discipline every build follows.

Verification

  1. With enforcement on, run document.body.innerHTML = "<b>x</b>" in a page’s console and confirm a TypeError.
  2. Exercise every page and feature; confirm no violations appear.
  3. Render a saved item with <img onerror> in its excerpt and confirm the sanitised output contains no handler.
  4. Confirm the extension still works in Firefox and Safari with the fallback policy.

FAQ

Does Trusted Types replace sanitisation?

No. It enforces that sanitisation happened through your policy. The policy still has to sanitise correctly.

What about eval and new Function?

The MV3 CSP already blocks them in extension pages; Trusted Types’ TrustedScript covers the remaining script sinks.

Is there a performance cost?

Negligible. The sanitiser itself costs time; Trusted Types only checks the value’s type.

Can a dependency bypass the policy?

Only by creating its own policy, which the trusted-types allow-list prevents unless you list its name. That is the point of naming policies: any new one is a deliberate decision recorded in the manifest.

Do offscreen documents need anything special?

They are extension pages and receive the same CSP. If an offscreen document parses HTML, route insertion through the policy like any other page.

How do I debug a TypeError from a third-party component?

The stack trace points at the sink assignment inside the library. Check whether the library offers a Trusted Types option; otherwise wrap the component, replace it, or give it a separately named policy that sanitises.

Other MV3 Architecture & Extension Lifecycle Resources