Trusted Types Is Baseline: the frontend guardrail you need before innerHTML
webdevelopment August 21, 2026 · Mintec

Trusted Types Is Baseline: the frontend guardrail you need before innerHTML

Trusted Types is now Baseline newly available. Use it to make CMS previews, rich text, and third-party widgets a small, reviewable boundary in Astro and Next.js projects.

Trusted Types Is Baseline: the frontend guardrail you need before innerHTML

Trusted Types is now Baseline newly available. For an Astro or Next.js site, that is not permission to scatter innerHTML with less anxiety. It is a chance to make every HTML insertion, script URL, and dynamic-code path a named, reviewable boundary. The useful implementation does not begin by switching CSP on in production. It begins by identifying which content actually needs HTML, sanitizing it at the right boundary, and letting the browser expose the paths that should not exist.[1]

The weak point is rarely the component someone wrote this sprint. It is the pile of exceptions that built up around it: a CMS preview, a rich-text block, a chat integration, a personalization snippet, a campaign embed. They converge on the same handful of APIs. A string reaches innerHTML, outerHTML, insertAdjacentHTML, srcdoc, or a script URL, and the browser gives that string more power than it had as plain text.

Composable architecture makes this easier to miss. The frontend receives CMS content, vendor modules, and route data through separate layers. That separation is valuable, as we cover in our composable web architecture. It also means the team has to state exactly where text is allowed to become DOM.

The browser is checking provenance, not trying to sanitize for you

Trusted Types does not ship with a magic sanitizer. MDN's definition is more useful: a team creates a policy with a transformation function, and the browser can require that input passes through that function before it reaches an injection sink. For HTML, the transformation commonly calls a sanitizer. For scripts and script URLs, it can allow only a tiny explicit set of inputs or disable the path outright.[1]

CSP turns that convention into a guardrail. With require-trusted-types-for 'script', assigning an ordinary string to a DOM XSS sink such as innerHTML throws a TypeError rather than rendering it. The related trusted-types directive can also restrict which policy names the application is allowed to create.[2]

That changes the engineering question. Instead of asking, "is this HTML clean?" we ask, "which module is allowed to mint TrustedHTML, and why?" The second question leaves a trail in code, CSP, and pull-request review.

Content pathThe right decisionWhat Trusted Types adds
UI text, labels, and normal dataRender as text or framework propsNo policy is needed; preserve the framework's ordinary escaping
CMS rich textSanitize at the content boundary; retain one HTML policy for client mutationsStops a later script from reinserting an unchecked string
Editorial preview or WYSIWYG editorIsolate it, inventory its sinks, and test it in report-only modeExposes dependencies that write HTML under the hood
Third-party widgetDemand compatibility or isolate it in an iframe where appropriateMakes the vendor's need for a sensitive sink visible
Dynamic scripts or legacy JSONPRemove it, or allow one reviewed URLShrinks a surface area that is almost never worth keeping generic

This is better than a blanket "never use innerHTML" rule. An editorial block may genuinely require markup; a product label does not. Treating both with the same fix is how teams end up with scattered sanitizers, different configurations, and exceptions nobody can audit.

Astro and Next.js protect the default path, not the exception path

Astro escapes ordinary rendered content. The boundary reopens with set:html, with an island that mutates the DOM after load, or with an editor passing HTML into preview. React is similar: its children are escaped by default, while dangerouslySetInnerHTML deliberately steps outside that protection.

Our architecture rule is straightforward: a server render receives HTML that has already been sanitized at the content layer. The client does not repeat that work inside ten components. If an island truly needs to replace a fragment, it calls an infrastructure function, not element.innerHTML from any feature module.

// src/lib/cms-html-policy.ts
import DOMPurify from "dompurify";

const cmsHtml = window.trustedTypes?.createPolicy("cms-html", {
  createHTML(input) {
    // The browser wraps this transformed string as TrustedHTML.
    return DOMPurify.sanitize(input, { RETURN_TRUSTED_TYPE: false });
  },
});

export function replaceCmsFragment(target: Element, rawHtml: string) {
  const clean = DOMPurify.sanitize(rawHtml, { RETURN_TRUSTED_TYPE: false });

  if (!cmsHtml) {
    target.innerHTML = clean; // Fallback where enforcement is unavailable.
    return;
  }

  target.innerHTML = cmsHtml.createHTML(clean);
}

The point of this example is not to ship DOMPurify in every client bundle. If the server has already sanitized the content, processing it again needs a specific reason. The point is ownership: the client-side mutation path lives in one module. The name cms-html appears in both code and CSP, which turns an ambiguous review into a two-minute search.

Do not turn a permissive policy into an escape hatch. The standard is explicit that a lax default policy can defeat the value of Trusted Types altogether. A default policy is a migration device for surfacing legacy dependencies; the final state should use narrowly scoped, explicit policies, or remove HTML sinks where they add no value.[3]

The rollout sequence we use before enforcing anything

The costly failure mode is enabling enforcement in the same deploy that changes the CMS, widgets, and router. When something breaks, nobody knows whether the policy, the integration, or the content caused it. We separate the migration into five verifiable contracts:

  1. Inventory the sinks. Search for innerHTML, outerHTML, insertAdjacentHTML, srcdoc, eval, new Function, and script.src. Include third-party tags, previews, and admin tooling, not just application code.
  2. Set a content boundary. For every rich-content path, define who sanitizes, which configuration they use, and what format they accept. If a block only needs text, remove its HTML path instead of protecting it.
  3. Observe before blocking. Send Content-Security-Policy-Report-Only with require-trusted-types-for 'script'. Collect violations by route, browser, and vendor. The W3C specification describes exactly this transition: observe, refactor, then enforce without impairing the application.[3]
  4. Name policies and assign an owner. Create a policy only for a real need, such as cms-html or video-embed-url. Declare it in trusted-types. Any new name has to explain why it cannot use an existing safe abstraction.
  5. Enforce and regression-test. Once first-party violations are resolved, switch the header to enforcement. Your smoke test needs CMS previews, rich-text routes, forms, real Server Islands, and production widgets, not a clean home page.

The final header is usually short:

Content-Security-Policy: require-trusted-types-for 'script'; trusted-types cms-html video-embed-url

That header does not replace backend validation, authentication, a complete CSP, or a security review of code that already controls the origin. Its job is narrower: stop a future change from turning an arbitrary string into executable HTML or script without crossing a boundary the team can inspect.

Our Mintec criterion: fewer dangerous places beats more policies

In architecture reviews, we do not reward a site for having five Trusted Types policies. We would rather see one small rich-text policy and none for everything else. When a team needs a new policy for every widget, it is usually accepting too much third-party logic in the main document.

That approach fits the server-first component model we describe in Declarative Shadow DOM: deliver stable HTML from the server, hydrate only where interaction calls for it, and keep DOM mutation as the exception. It is also a concrete answer to the maintenance debt we see when hardening AI-assisted builds. The risk is not that an AI knows innerHTML; it is that nobody can point to the owner of every path that reaches it. Our production security framework for vibe-coded projects covers that wider deployment gate.

Trusted Types does not replace technical judgment. It makes judgment enforceable. For sites that combine a headless CMS, interactive islands, and media from several vendors, that is more useful than another generic security checklist: the next shortcut leaves evidence before it reaches production.

Sources

[1] https://developer.mozilla.org/en-US/docs/Web/API/Trusted_Types_API — MDN: Trusted Types API [2] https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Content-Security-Policy/require-trusted-types-for — MDN: CSP require-trusted-types-for [3] https://w3c.github.io/trusted-types/dist/spec — W3C: Trusted Types specification

Frequently Asked Questions

What are Trusted Types?

They are a browser API that requires values headed for sensitive DOM sinks such as innerHTML to pass through a team-defined policy. That policy can sanitize HTML, and CSP can reject direct strings at those sinks.

Do Astro or React remove the need for Trusted Types?

No. They protect many normal render paths, but set:html, dangerouslySetInnerHTML, CMS previews, and client widgets reopen a DOM injection boundary. Trusted Types protects that boundary in the browser.

How do you adopt Trusted Types without breaking production?

Start by inventorying sinks and deploy Content-Security-Policy-Report-Only. Centralize the policies, fix violations from application code and vendors, then enforce require-trusted-types-for 'script'.

Related Articles