You are viewing a preview of this lesson. Sign in to start learning
Back to Web Security: The Modern Browser Model

Trusted Types for DOM XSS

Eliminate DOM-based XSS vulnerabilities using Trusted Types API and CSP enforcement

Introduction: The DOM XSS Problem and Trusted Types Solution

You've locked down your web application with Content Security Policy. You've blocked inline scripts, whitelisted trusted domains, and carefully configured every directive. Your CSP headers are pristine. But then, during a security audit, a penetration tester demonstrates something unsettling: they inject malicious code through a search feature, and despite your bulletproof CSP, the attack succeeds. Welcome to the frustrating world of DOM-based XSSβ€”a vulnerability that slips right past traditional defenses. If you've ever wondered why some XSS attacks seem immune to CSP, you'll appreciate having free flashcards and comprehensive guidance on Trusted Types, the modern browser security mechanism designed specifically to close this dangerous gap.

The uncomfortable truth is that Content Security Policy (CSP) was never designed to prevent all forms of cross-site scripting. Traditional CSP excels at controlling where resources come fromβ€”blocking unauthorized script sources, preventing inline event handlers, and restricting dangerous practices like eval(). But CSP has a blind spot: it cannot see what your JavaScript does after it loads. When your legitimate, CSP-approved JavaScript dynamically manipulates the DOM in unsafe ways, CSP simply watches from the sidelines.

The Achilles' Heel: DOM Injection Sinks

Consider this seemingly innocent code that developers write every day:

// User searches for products
const searchTerm = new URLSearchParams(window.location.search).get('query');
const resultsContainer = document.getElementById('results');

// Display search results header
resultsContainer.innerHTML = `<h2>Results for: ${searchTerm}</h2>`;

This code passes every CSP check. Your JavaScript file is hosted on your trusted domain. No inline scripts are being created from the HTML perspective. But what happens when an attacker crafts a URL like ?query=<img src=x onerror=alert(document.cookie)>? The innerHTML property dutifully parses that string as HTML, creating an <img> element that immediately executes JavaScript when it fails to load. Your CSP never saw it coming because, from CSP's perspective, your own trusted JavaScript performed the operation.

🎯 Key Principle: Traditional CSP prevents untrusted sources from running code, but it cannot prevent trusted code from being tricked into creating executable content from untrusted data.

These vulnerable entry points are called injection sinksβ€”DOM APIs that convert strings into executable code or markup. The most dangerous sinks include:

πŸ”’ innerHTML and outerHTML - Parse strings as HTML, creating elements and executing scripts πŸ”’ eval() and Function() - Directly execute strings as JavaScript code
πŸ”’ script.src, script.text, script.textContent - Create or modify script elements πŸ”’ document.write() and document.writeln() - Insert content that gets parsed as HTML πŸ”’ element.setAttribute() - Can create event handlers when setting attributes like onclick πŸ”’ location.href and location.assign() - Can execute javascript: URLs

πŸ’‘ Real-World Example: In 2018, researchers found that over 40% of applications using CSP were still vulnerable to DOM XSS because they relied on CSP to protect unsafe DOM manipulation patterns. CSP's adoption was high, but its effectiveness against DOM-based attacks was limited.

Enter Trusted Types: Type Safety for the DOM

Trusted Types represents a paradigm shift in how browsers handle potentially dangerous DOM operations. Instead of accepting any string for dangerous operations, Trusted Types transforms these APIs to only accept special, type-safe objects that have been explicitly created through security policies.

The mental model is elegant: if innerHTML is like an unlocked door that accepts any visitor (any string), Trusted Types turns it into a secured entrance that only admits guests with proper credentials (typed objects).

// WITHOUT Trusted Types (dangerous)
element.innerHTML = userInput; // Accepts any string!

// WITH Trusted Types (secure)
element.innerHTML = userInput; // TypeError! String not allowed

// You must create a TrustedHTML object through a policy
const policy = trustedTypes.createPolicy('myPolicy', {
  createHTML: (input) => {
    // Your sanitization logic here
    return DOMPurify.sanitize(input);
  }
});

element.innerHTML = policy.createHTML(userInput); // βœ… Works!

When Trusted Types is enabled, the browser fundamentally changes how injection sinks behave. Instead of silently accepting strings that might contain malicious code, these APIs reject all strings and only accept specially-typed objects:

πŸ“‹ Quick Reference Card: Trusted Type Objects

🎯 Type πŸ“ Purpose πŸ”§ Used For
TrustedHTML Sanitized HTML markup innerHTML, outerHTML, document.write()
TrustedScript Safe JavaScript code script.src, script.text, eval()
TrustedScriptURL Validated script URLs script.src, import()
TrustedURL Safe URLs for resources iframe.src, embed.src, object.data

The Enforcement Mechanism

Trusted Types integrates with Content Security Policy as a new directive, creating a powerful defense-in-depth strategy:

Content-Security-Policy: require-trusted-types-for 'script'; trusted-types myPolicy

This CSP directive tells the browser: "For any operation that could execute scripts (the 'script' category), require Trusted Types. Only accept typed objects created by the policy named 'myPolicy'." When this is active, attempting to pass a raw string to a dangerous sink results in a TypeError that halts execution:

// With Trusted Types enforced via CSP:
document.body.innerHTML = '<div>Hello</div>'; 
// ❌ Uncaught TypeError: Failed to set the 'innerHTML' property on 'Element': 
//    This document requires 'TrustedHTML' assignment.

πŸ€” Did you know? Trusted Types was developed by Google's security team after analyzing thousands of real-world XSS vulnerabilities. They found that nearly all DOM XSS could be prevented by controlling just 30 dangerous DOM APIs.

Why This Matters for Your Security Toolkit

Traditional web security often felt like an arms raceβ€”developers patch one vulnerability, attackers find another vector. Trusted Types changes the game by addressing entire classes of vulnerabilities at once. Instead of hunting down every place where user input touches the DOM, you define security policies once and let the browser enforce them everywhere.

Here's what mastering Trusted Types enables you to do:

🧠 Shift security left - Catch injection vulnerabilities during development, not in production
πŸ”§ Enforce secure patterns - Make it impossible for developers to accidentally introduce DOM XSS
🎯 Audit security - Know exactly where sanitization happens (only in policies)
πŸ”’ Defense in depth - Layer Trusted Types with CSP for comprehensive protection
πŸ“š Future-proof code - Align with modern browser security standards

πŸ’‘ Mental Model: Think of Trusted Types as TypeScript for security. Just as TypeScript catches type errors at development time that would crash at runtime, Trusted Types catches security errors where strings are used dangerously. The difference? Trusted Types enforcement happens in the browser itself.

The Relationship with Content Security Policy

Trusted Types doesn't replace CSPβ€”it enhances it. Where traditional CSP directives control resource loading (script-src, style-src, etc.), Trusted Types controls DOM manipulation. Together, they form a comprehensive defense:

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚   Web Application Security Layers       β”‚
β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
β”‚                                         β”‚
β”‚  Traditional CSP Directives             β”‚
β”‚  β”œβ”€ script-src: Control script sources β”‚
β”‚  β”œβ”€ style-src: Control CSS sources     β”‚
β”‚  └─ default-src: Fallback policy       β”‚
β”‚                                         β”‚
β”‚  Trusted Types (via CSP)                β”‚
β”‚  β”œβ”€ require-trusted-types-for 'script' β”‚
β”‚  └─ trusted-types: policy-name         β”‚
β”‚                                         β”‚
β”‚  Result: Defense in Depth               β”‚
β”‚  βœ“ Block unauthorized sources (CSP)    β”‚
β”‚  βœ“ Block DOM injection (Trusted Types) β”‚
β”‚                                         β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

⚠️ Common Mistake: Assuming CSP alone provides complete XSS protection. Even with a strict CSP, DOM-based XSS can bypass protections. Always combine CSP with Trusted Types for modern applications. ⚠️

Looking Ahead

Understanding why Trusted Types exists is the foundation. The mechanism transforms browser security from a reactive "catch every bug" approach to a proactive "make bugs impossible" paradigm. In the sections ahead, you'll learn exactly how the type enforcement works under the hood, how to implement policies in real applications, and how to navigate the practical challenges of deploying Trusted Types in existing codebases.

The journey from understanding the problem to implementing the solution starts with recognizing that string-based DOM manipulation is fundamentally unsafe. Trusted Types gives us the tools to enforce safety at the platform levelβ€”but only if we understand how to wield those tools effectively.

How Trusted Types Enforce Secure-by-Default DOM Manipulation

Trusted Types fundamentally reimagines how browsers handle potentially dangerous DOM operations. Instead of allowing any string to flow into security-sensitive APIs, Trusted Types transforms the browser into a type-checking enforcer that only accepts specially-blessed objects. This shift from string-based to type-based security creates a powerful defense layer that makes entire classes of DOM XSS vulnerabilities nearly impossible to introduce.

The Injection Sink Concept

At the heart of Trusted Types lies the concept of injection sinksβ€”specific DOM APIs that can execute code or interpret strings as HTML. These are the dangerous entry points where untrusted data becomes executable content. Without Trusted Types, these sinks happily accept any string:

// Traditional (dangerous) DOM manipulation - injection sinks accept strings
element.innerHTML = userInput;  // Injection sink!
scriptEl.src = apiURL;           // Injection sink!
eval(userCode);                  // Injection sink!
location.href = userRedirect;    // Injection sink!

Each of these seemingly innocent assignments represents a potential security hole. The browser has no way to distinguish between safe, developer-intended strings and malicious, attacker-controlled input. They're all just strings.

🎯 Key Principle: Trusted Types locks down approximately 20+ dangerous DOM APIs (injection sinks) to only accept typed objects instead of raw strings. When enabled, passing a string to these sinks throws a TypeError.

Here's the mental model:

BEFORE TRUSTED TYPES:
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ Any String  β”‚ ──────► innerHTML ──► Executes/Renders
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

WITH TRUSTED TYPES:
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ Raw String  β”‚ ──X──► innerHTML ──► TypeError!
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ TrustedHTML      β”‚ β”€β”€βœ“β”€β”€β–Ί innerHTML ──► Executes/Renders
β”‚ (policy-created) β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

The Three Trusted Types

Trusted Types introduces three new object types, each protecting different categories of injection sinks:

TrustedHTML protects sinks that interpret strings as HTML markup. These include innerHTML, outerHTML, insertAdjacentHTML(), and similar APIs that parse HTML. When you need to insert rich content into the DOM, you must create a TrustedHTML object:

// Creating a TrustedHTML object through a policy
const policy = trustedTypes.createPolicy('myPolicy', {
  createHTML: (input) => {
    // Sanitization logic here
    return input.replace(/</g, '&lt;');
  }
});

const safeHTML = policy.createHTML('<b>Bold text</b>');
element.innerHTML = safeHTML;  // βœ… Works - receives TrustedHTML

TrustedScript guards sinks that execute JavaScript code directly. These include eval(), setTimeout() with string arguments, Function() constructor, and script element text content. This type is particularly sensitive because it represents direct code execution:

const policy = trustedTypes.createPolicy('scriptPolicy', {
  createScript: (input) => {
    // Extremely careful validation needed here
    if (!/^[a-zA-Z0-9_]+$/.test(input)) {
      throw new Error('Invalid script content');
    }
    return input;
  }
});

const safeScript = policy.createScript('console.log("safe")');
eval(safeScript);  // βœ… Works - receives TrustedScript

πŸ’‘ Pro Tip: In practice, you should almost never need TrustedScript. Most applications can avoid string-based code execution entirely. If you find yourself creating TrustedScript policies frequently, reconsider your architecture.

TrustedScriptURL protects sinks that load and execute scripts from URLs. These include <script src>, <iframe src> (in some contexts), and worker creation. This prevents attackers from redirecting script loads to malicious servers:

const policy = trustedTypes.createPolicy('urlPolicy', {
  createScriptURL: (input) => {
    // Only allow scripts from trusted origins
    const url = new URL(input, document.baseURI);
    if (url.origin === 'https://trusted-cdn.example.com') {
      return url.href;
    }
    throw new Error('Untrusted script origin');
  }
});

const scriptEl = document.createElement('script');
scriptEl.src = policy.createScriptURL('https://trusted-cdn.example.com/lib.js');

Browser Enforcement via CSP

Trusted Types becomes active through Content Security Policy (CSP). The browser doesn't enforce type checking by defaultβ€”you must explicitly opt in. When you add the require-trusted-types-for directive to your CSP header, the browser transforms all injection sinks:

Content-Security-Policy: require-trusted-types-for 'script'

With this single directive enabled, every injection sink in your application immediately begins rejecting raw strings. This is a secure-by-default transformationβ€”the browser actively prevents potentially dangerous operations rather than passively hoping developers remember to sanitize.

⚠️ Common Mistake: Enabling Trusted Types without any policies defined will break most existing applications immediately. Plan for a migration strategy using report-only mode first:

Content-Security-Policy-Report-Only: require-trusted-types-for 'script'

πŸ€” Did you know? When Trusted Types is enabled, violations generate detailed console errors and (optionally) CSP violation reports, making it much easier to identify exactly where unsafe DOM manipulation occurs in your codebase.

Default vs. Named Policies

Trusted Types supports two policy strategies: named policies and a special default policy.

Named policies are explicitly created with specific purposes. You've seen these in the examples aboveβ€”they're created with trustedTypes.createPolicy('policyName', {...}). Each named policy has distinct sanitization rules:

// Policy for user-generated content - aggressive sanitization
const ugcPolicy = trustedTypes.createPolicy('user-content', {
  createHTML: (input) => {
    return DOMPurify.sanitize(input, {ALLOWED_TAGS: ['b', 'i', 'p']});
  }
});

// Policy for admin content - more permissive
const adminPolicy = trustedTypes.createPolicy('admin-content', {
  createHTML: (input) => {
    return DOMPurify.sanitize(input, {ALLOWED_TAGS: ['b', 'i', 'p', 'img', 'a']});
  }
});

// Use the appropriate policy based on context
element.innerHTML = ugcPolicy.createHTML(userComment);
adminPanel.innerHTML = adminPolicy.createHTML(adminContent);

The default policy serves as a fallback when raw strings encounter injection sinks. It's created with the special name 'default':

trustedTypes.createPolicy('default', {
  createHTML: (input) => {
    console.warn('Default policy converting string to TrustedHTML:', input);
    return input;  // Pass through (not recommended for production!)
  },
  createScript: (input) => {
    throw new Error('No script execution allowed via default policy');
  },
  createScriptURL: (input) => {
    throw new Error('No dynamic script loading via default policy');
  }
});

// Now strings automatically convert via default policy
element.innerHTML = '<p>Text</p>';  // Logs warning, then works

⚠️ Important: Only one default policy can exist per document. Attempting to create a second default policy throws an error. Additionally, you can restrict default policy creation using CSP:

Content-Security-Policy: 
  require-trusted-types-for 'script';
  trusted-types myPolicy anotherPolicy  /* No 'default' allowed! */

πŸ’‘ Real-World Example: A common migration strategy uses a permissive default policy during development that logs violations but allows them through, then gradually creates named policies for specific use cases, and finally removes the default policy for production.

Before and After: The Transformation

Let's see how Trusted Types transforms a realistic application scenario. Consider a chat application that displays user messages:

Before Trusted Types (vulnerable):

function displayMessage(username, message) {
  const messageDiv = document.createElement('div');
  
  // Vulnerable to XSS if username or message contains <script>
  messageDiv.innerHTML = `
    <strong>${username}</strong>: ${message}
  `;
  
  chatContainer.appendChild(messageDiv);
}

// Attacker sends: username="<img src=x onerror=alert('XSS')>"
displayMessage(attackerUsername, attackerMessage);  // πŸ’₯ XSS!

After Trusted Types (protected):

// Create a policy for chat messages
const chatPolicy = trustedTypes.createPolicy('chat-messages', {
  createHTML: (input) => {
    // Use a proper sanitizer - this example shows the concept
    return input
      .replace(/&/g, '&amp;')
      .replace(/</g, '&lt;')
      .replace(/>/g, '&gt;')
      .replace(/"/g, '&quot;')
      .replace(/'/g, '&#x27;');
  }
});

function displayMessage(username, message) {
  const messageDiv = document.createElement('div');
  
  // Create safe HTML through policy
  const safeHTML = chatPolicy.createHTML(
    `<strong>${username}</strong>: ${message}`
  );
  
  messageDiv.innerHTML = safeHTML;  // βœ… Safe - TrustedHTML object
  chatContainer.appendChild(messageDiv);
}

// Attacker's payload gets escaped automatically
displayMessage(attackerUsername, attackerMessage);  // βœ… Displays as text, not executed

🎯 Key Principle: The fundamental shift is from "remember to sanitize" (developer responsibility, often forgotten) to "cannot assign without sanitizing" (browser enforcement, impossible to forget).

πŸ“‹ Quick Reference Card:

Type Protected Sinks Common Use Cases
πŸ”’ TrustedHTML innerHTML, outerHTML, insertAdjacentHTML Rich content, templates, user-generated markup
πŸ”’ TrustedScript eval(), new Function(), setTimeout(string) Dynamic code (avoid if possible)
πŸ”’ TrustedScriptURL script.src, import(), Worker() Loading external scripts from CDNs

πŸ’‘ Mental Model: Think of Trusted Types as a mandatory security checkpoint. Raw strings are passengers without boarding passesβ€”they can't board the plane (reach injection sinks). Trusted Type objects are passengers with verified boarding passes (created through policies with security checks).

The elegance of Trusted Types lies in its simplicity: it doesn't try to automatically sanitize everything. Instead, it forces you to make sanitization decisions explicit and centralized in policies. This makes security reviewable, testable, and enforceable by the browser itself.

Implementing Trusted Types in Real Applications

Now that we understand what Trusted Types are and how they work conceptually, it's time to get our hands dirty with real implementation. Think of this section as your practical guide to deploying Trusted Types in a production applicationβ€”we'll start with the easiest, safest approach and gradually move toward full enforcement.

Starting with Report-Only Mode

The first rule of implementing Trusted Types is: never start with enforcement mode. Instead, begin with report-only mode, which allows you to see where violations would occur without actually breaking your application. This is done through the Content Security Policy (CSP) header.

Here's how the progression looks:

No Protection β†’ Report-Only Monitoring β†’ Enforcement
     (current state)      (safe testing)       (full security)

To enable report-only mode, add this CSP header to your HTTP responses:

Content-Security-Policy-Report-Only: require-trusted-types-for 'script'; report-uri /csp-reports

This header tells the browser: "Flag any DOM XSS sinks that receive strings, but don't actually block themβ€”just send me reports." The require-trusted-types-for 'script' directive is the key piece that activates Trusted Types checking for JavaScript code.

πŸ’‘ Pro Tip: Set up a simple endpoint at /csp-reports to collect violation reports. Even a basic logging system will help you understand which parts of your codebase need attention. You'll receive JSON reports detailing exactly which line of code triggered a violation and what API was called.

After running in report-only mode for a few days or weeks (depending on your application's complexity and traffic patterns), you'll have a clear picture of all the unsafe DOM operations in your code. This data becomes your implementation roadmap.

🎯 Key Principle: Trusted Types adoption is a journey, not a destination. Report-only mode gives you a safe path to walk before you commit to enforcement.

Creating Your First Trusted Types Policy

Once you've identified the violations, you need to create Trusted Types policies to handle them. A policy is essentially a sanitization factoryβ€”it takes untrusted strings and produces typed objects that the browser will accept at dangerous sinks.

Here's a basic policy for HTML content:

// Create a policy for general HTML content
const sanitizerPolicy = trustedTypes.createPolicy('my-sanitizer', {
  // This function runs whenever you create TrustedHTML
  createHTML: (input) => {
    // Option 1: Use DOMPurify or another sanitizer library
    // return DOMPurify.sanitize(input, {RETURN_TRUSTED_TYPE: true});
    
    // Option 2: Simple escaping (for demonstration)
    const div = document.createElement('div');
    div.textContent = input; // This escapes HTML entities
    return div.innerHTML;
  },
  
  // For script content (use very carefully!)
  createScript: (input) => {
    // Usually you want to reject most inputs here
    throw new TypeError('Script creation not allowed');
  },
  
  // For URLs (like href, src)
  createScriptURL: (input) => {
    // Validate that URLs are safe
    const url = new URL(input, document.baseURI);
    if (url.protocol === 'https:' || url.protocol === 'http:') {
      return url.href;
    }
    throw new TypeError('Only HTTP(S) URLs allowed');
  }
});

Policy names (like 'my-sanitizer' above) must be unique in your application. The browser will throw an error if you try to create two policies with the same name.

⚠️ Common Mistake: Creating a policy that just returns the input unchanged defeats the entire purpose of Trusted Types. Your createHTML, createScript, and createScriptURL functions should actually sanitize or validate their inputs. ⚠️

The policy object has three methods corresponding to the three trusted types:

  • createHTML() β†’ produces TrustedHTML objects
  • createScript() β†’ produces TrustedScript objects
  • createScriptURL() β†’ produces TrustedScriptURL objects

Converting Unsafe Code to Trusted Types

Let's look at real-world examples of converting dangerous DOM operations to use Trusted Types. Here's the transformation pattern you'll apply repeatedly:

// ❌ BEFORE: Unsafe string assignment
const userComment = getUserInput(); // Imagine this is: "<img src=x onerror=alert(1)>"
element.innerHTML = userComment; // XSS vulnerability!

// βœ… AFTER: Trusted Types compliant
const userComment = getUserInput();
const sanitized = sanitizerPolicy.createHTML(userComment);
element.innerHTML = sanitized; // Safe! Type system enforces sanitization

The magic here is that element.innerHTML no longer accepts strings when Trusted Types is enforced. It only accepts TrustedHTML objects. The only way to create a TrustedHTML object is through a policy, which means your sanitization logic must run.

Here's a more complex example showing multiple violation types:

// Real-world scenario: Dynamic dashboard widget
class DashboardWidget {
  constructor(containerId, config) {
    this.container = document.getElementById(containerId);
    this.config = config;
  }
  
  // ❌ UNSAFE: Multiple Trusted Types violations
  renderUnsafe() {
    // Violation 1: innerHTML with string
    this.container.innerHTML = `
      <div class="widget">
        <h3>${this.config.title}</h3>
        <p>${this.config.description}</p>
      </div>
    `;
    
    // Violation 2: script.src with string
    const script = document.createElement('script');
    script.src = this.config.analyticsUrl;
    document.head.appendChild(script);
  }
  
  // βœ… SAFE: Trusted Types compliant
  renderSafe() {
    // Use policy for HTML content
    const widgetHTML = sanitizerPolicy.createHTML(`
      <div class="widget">
        <h3>${this.config.title}</h3>
        <p>${this.config.description}</p>
      </div>
    `);
    this.container.innerHTML = widgetHTML;
    
    // Use policy for script URLs
    const script = document.createElement('script');
    const safeUrl = sanitizerPolicy.createScriptURL(this.config.analyticsUrl);
    script.src = safeUrl;
    document.head.appendChild(script);
  }
}

πŸ’‘ Real-World Example: At Google, the migration to Trusted Types across their products revealed thousands of potential XSS vulnerabilitiesβ€”many that would have been nearly impossible to find through manual code review or traditional testing.

Using Browser DevTools to Debug Violations

When you enable Trusted Types, your browser DevTools console becomes your best friend. The browser provides detailed violation messages that pinpoint exactly where problems occur:

Trusted Type violation: This document requires 'TrustedHTML' assignment.
  at HTMLDivElement.set innerHTML [as innerHTML] (<anonymous>)
  at DashboardWidget.renderUnsafe (widget.js:15:28)
  at new DashboardWidget (widget.js:8:10)

The error tells you:

  • 🎯 What was violated (innerHTML requires TrustedHTML)
  • πŸ“ Where it happened (widget.js, line 15, column 28)
  • πŸ” The call stack leading to the violation

To see all violations in Chrome DevTools:

  1. Open DevTools (F12)
  2. Go to the Console tab
  3. Filter for "Trusted Type" messages
  4. In report-only mode, these appear as warnings; in enforcement mode, they're errors

πŸ”§ Debugging workflow:

Enable report-only β†’ Gather violations β†’ Prioritize by frequency β†’
  Create policies β†’ Convert code β†’ Test β†’ Monitor β†’ Enforce

Incremental Adoption Strategies

The biggest challenge with Trusted Types isn't the technical implementationβ€”it's managing the migration without breaking your application. Here are battle-tested strategies:

Strategy 1: The Default Policy Escape Hatch

You can create a special default policy that acts as a last resort for any violations. This allows you to adopt Trusted Types gradually:

// Create a default policy for legacy code
trustedTypes.createPolicy('default', {
  createHTML: (input) => {
    console.warn('Default policy used for:', input);
    // Log to monitoring system - these are tech debt!
    sendToMonitoring('default-policy-used', {input});
    
    // Sanitize, but mark for future cleanup
    return DOMPurify.sanitize(input);
  }
});

⚠️ Important Warning: Only one default policy can exist per page. Use it as a temporary bridge, not a permanent solution. Every default policy usage represents technical debt that should eventually be replaced with explicit policies. ⚠️

Strategy 2: Feature-Based Rollout

Don't try to convert your entire codebase at once. Instead:

πŸ”§ Phase 1: Enable report-only globally, implement for new features only
πŸ”§ Phase 2: Convert high-traffic pages one at a time
πŸ”§ Phase 3: Tackle low-traffic legacy code
πŸ”§ Phase 4: Remove default policy, enable full enforcement

Strategy 3: Framework Integration

If you use a modern framework like React, Vue, or Angular, check if it has built-in Trusted Types support:

  • React 16+: Automatically compatible, but ensure you're not using dangerouslySetInnerHTML without sanitization
  • Angular 12+: Native Trusted Types support with security contexts
  • Vue 3+: Compatible with proper template usage

πŸ€” Did you know? Modern frameworks generally protect you from XSS by default, but Trusted Types provides an additional enforcement layer that catches bypasses and edge cases that slip through.

Monitoring and Measuring Success

Once you've deployed Trusted Types, ongoing monitoring is crucial:

πŸ“‹ Quick Reference Card: Metrics to Track

πŸ“Š Metric 🎯 Goal πŸ” What It Tells You
🚨 Violation Count Trending to zero How much work remains
πŸ›‘οΈ Policy Usage Stable patterns Where sanitization happens
⚠️ Default Policy Hits Decreasing Technical debt remaining
πŸ› Runtime Errors No increase Migration quality

Set up dashboards that track these metrics over time. A successful Trusted Types deployment shows:

βœ… Correct thinking: Violations drop steadily, default policy usage decreases, no new user-facing errors
❌ Wrong thinking: "Zero violations on day one"β€”that's unrealistic for any sizeable application

πŸ’‘ Remember: Trusted Types is a security boundary, not a silver bullet. You still need to write good sanitization logic within your policies. The framework ensures that logic runs; it doesn't write the logic for you.

Enabling Full Enforcement

When you're readyβ€”after weeks or months of monitoring, fixing violations, and testingβ€”you can enable full enforcement by changing your CSP header:

Content-Security-Policy: require-trusted-types-for 'script'; trusted-types my-sanitizer default

Notice the differences from report-only mode:

  • Changed from Content-Security-Policy-Report-Only to Content-Security-Policy
  • Added trusted-types directive listing allowed policy names
  • Now violations block the operation instead of just reporting it

The trusted-types directive creates an allowlist of policy names. Only policies in this list can be created, preventing malicious code from creating its own permissive policies.

🎯 Key Principle: Enforcement mode transforms Trusted Types from a monitoring tool into an active defense mechanism. At this point, DOM XSS attacks become architecturally impossibleβ€”they're not just caught, they're prevented by the type system itself.

With these practical patterns in hand, you're now equipped to deploy Trusted Types in a real application. The key is patienceβ€”this is a marathon, not a sprint. Start with report-only mode, fix violations systematically, and gradually move toward enforcement. Your future self (and your security team) will thank you.

Common Pitfalls and Best Practices

Implementing Trusted Types correctly requires more than just enabling the featureβ€”it demands thoughtful policy design and a clear understanding of common failure modes. Even well-intentioned developers can inadvertently compromise security by creating policies that look correct on the surface but actually recreate the vulnerabilities Trusted Types were meant to prevent. Let's explore the most common pitfalls and learn how to build truly secure implementations.

Pitfall 1: The Overly Permissive Default Policy

⚠️ Common Mistake 1: Creating a "catch-all" default policy that defeats the entire purpose of Trusted Types ⚠️

The most dangerous mistake is creating a default policy that simply accepts any input without sanitization. Remember, a default policy is invoked automatically when strings are assigned to dangerous sinks, making it incredibly tempting to use as a "quick fix" to make legacy code work.

// ❌ DANGEROUS: This defeats all security benefits
if (window.trustedTypes && trustedTypes.createPolicy) {
  trustedTypes.createPolicy('default', {
    createHTML: (input) => input,  // Just returns the input unchanged!
    createScript: (input) => input,
    createScriptURL: (input) => input
  });
}

// Now this code "works" but is completely vulnerable:
element.innerHTML = userInput;  // Default policy allows anything through

❌ Wrong thinking: "I'll create a permissive default policy to make my app work, then tighten it later."

βœ… Correct thinking: "Default policies should only exist for specific, well-understood legacy cases. Most code should use explicit named policies with strict sanitization."

🎯 Key Principle: If your default policy doesn't reject potentially dangerous input, you've simply moved the vulnerability from the DOM API to the policy itself. The attack surface remains unchanged.

A better approach is to avoid default policies entirely or make them extremely restrictive:

// βœ… BETTER: Restrictive default policy that only handles known-safe cases
trustedTypes.createPolicy('default', {
  createHTML: (input) => {
    // Only allow if it's coming from a specific, audited code path
    if (input === '<span class="loading">Loading...</span>') {
      return input;
    }
    // Log the violation for investigation
    console.error('Blocked unsafe HTML:', input);
    throw new TypeError('Use an explicit policy for HTML creation');
  },
  createScriptURL: (url) => {
    // Only allow URLs from our own domain
    const allowed = ['https://cdn.example.com/', 'https://example.com/'];
    if (allowed.some(prefix => url.startsWith(prefix))) {
      return url;
    }
    throw new TypeError('Script URLs must be from allowed origins');
  }
});

πŸ’‘ Pro Tip: Treat default policies as temporary scaffolding during migration. Set a deadline to remove or minimize them. Every line in a default policy is a potential security hole that needs ongoing audit.

Pitfall 2: Wrapping Without Sanitizing

The second major pitfall is treating policy functions as mere type converters rather than security gatekeepers.

⚠️ Common Mistake 2: Using policy functions as a wrapper without actual sanitization ⚠️

// ❌ INSECURE: Just wrapping input doesn't make it safe
const unsafePolicy = trustedTypes.createPolicy('user-content', {
  createHTML: (input) => {
    // This is just a pass-through with extra steps!
    return input;
  }
});

const userComment = '<img src=x onerror=alert(1)>';
element.innerHTML = unsafePolicy.createHTML(userComment);
// Still vulnerable to XSS!

The policy function must actively sanitize, validate, or escape the input. This typically means:

πŸ”’ For HTML: Use a proper HTML sanitization library (like DOMPurify)

πŸ”’ For Scripts: Validate against a strict allowlist or use templating

πŸ”’ For Script URLs: Check against allowed origins and paths

// βœ… SECURE: Actual sanitization inside the policy
import DOMPurify from 'dompurify';

const htmlPolicy = trustedTypes.createPolicy('sanitized-html', {
  createHTML: (input) => {
    // DOMPurify removes dangerous elements and attributes
    return DOMPurify.sanitize(input, {
      ALLOWED_TAGS: ['b', 'i', 'em', 'strong', 'p', 'br'],
      ALLOWED_ATTR: []
    });
  }
});

// Now this is actually safe:
const userComment = '<img src=x onerror=alert(1)><b>Hello</b>';
element.innerHTML = htmlPolicy.createHTML(userComment);
// Result: "<b>Hello</b>" - dangerous content removed

πŸ’‘ Real-World Example: A major e-commerce platform initially created policies that just wrapped input strings, assuming the type system itself provided protection. After a security audit, they discovered they were still vulnerable to XSS and had to retrofit actual sanitization logic into all their policies.

Pitfall 3: Using the Wrong Trusted Type

Trusted Types provides three distinct types for a reasonβ€”each serves a different security context.

🎯 Key Principle: The type must match the sink's security requirements. Using the wrong type can lead to bypasses or runtime errors.

The three types and their purposes:

πŸ“‹ Quick Reference Card:

Type Use For Example Sinks Security Goal
πŸ”΅ TrustedHTML Markup content innerHTML, outerHTML, insertAdjacentHTML Prevent script injection via HTML
🟒 TrustedScript Executable code eval(), new Function(), inline event handlers Prevent arbitrary code execution
🟑 TrustedScriptURL Script sources <script src>, import(), worker scripts Prevent loading malicious external scripts

⚠️ Common Mistake 3: Using TrustedHTML when TrustedScriptURL is required ⚠️

// ❌ WRONG: This will fail or bypass security
const htmlPolicy = trustedTypes.createPolicy('html', {
  createHTML: (s) => s
});

const script = document.createElement('script');
script.src = htmlPolicy.createHTML('https://cdn.example.com/app.js');
// TypeError! script.src expects TrustedScriptURL, not TrustedHTML
// βœ… CORRECT: Use the appropriate type for the sink
const scriptUrlPolicy = trustedTypes.createPolicy('cdn-scripts', {
  createScriptURL: (url) => {
    if (url.startsWith('https://cdn.example.com/')) {
      return url;
    }
    throw new TypeError('Scripts must come from approved CDN');
  }
});

const script = document.createElement('script');
script.src = scriptUrlPolicy.createScriptURL('https://cdn.example.com/app.js');
// Works correctly!

🧠 Mnemonic: HTML for Holding content, Script for Statements, ScriptURL for Sources.

Best Practice 1: Keep Policies Minimal, Auditable, and Centralized

Security is easier to verify when the attack surface is small and visible.

Centralization strategy:

// βœ… GOOD: All policies in one auditable module
// policies.js
export const sanitizedHtmlPolicy = trustedTypes.createPolicy('app-html', {
  createHTML: (input) => DOMPurify.sanitize(input, STRICT_CONFIG)
});

export const templatePolicy = trustedTypes.createPolicy('app-templates', {
  createHTML: (templateId) => {
    // Only accept predefined template IDs
    const templates = {
      'user-card': '<div class="card">...</div>',
      'alert-box': '<div class="alert">...</div>'
    };
    if (!(templateId in templates)) {
      throw new Error(`Unknown template: ${templateId}`);
    }
    return templates[templateId];
  }
});

// Import and use throughout your app
// component.js
import { sanitizedHtmlPolicy } from './policies.js';

element.innerHTML = sanitizedHtmlPolicy.createHTML(userInput);

πŸ”§ Benefits of centralization:

  • Auditability: Security team reviews one file, not scattered policies
  • Consistency: Same sanitization rules everywhere
  • Maintainability: Update sanitization logic in one place
  • Testing: Easier to write comprehensive security tests

πŸ’‘ Pro Tip: Limit your application to 2-4 policies maximum. If you find yourself creating many policies, you're likely not sanitizing at the right architectural layer.

Best Practice 2: Performance Considerations

While Trusted Types adds minimal overhead, improper usage can create performance problems.

Avoid repeated policy invocations in loops:

// ❌ INEFFICIENT: Creating trusted values in a hot loop
for (let i = 0; i < 10000; i++) {
  const item = document.createElement('div');
  item.innerHTML = htmlPolicy.createHTML(`<span>Item ${i}</span>`);
  container.appendChild(item);
}
// βœ… BETTER: Build the HTML once, then parse
const items = [];
for (let i = 0; i < 10000; i++) {
  items.push(`<div><span>Item ${i}</span></div>`);
}
container.innerHTML = htmlPolicy.createHTML(items.join(''));

// βœ… EVEN BETTER: Avoid innerHTML entirely for static structures
for (let i = 0; i < 10000; i++) {
  const div = document.createElement('div');
  const span = document.createElement('span');
  span.textContent = `Item ${i}`;  // textContent doesn't need Trusted Types!
  div.appendChild(span);
  container.appendChild(div);
}

🎯 Key Principle: The fastest sanitization is the one you don't need to do. Use DOM APIs like textContent, setAttribute(), and createElement() when possibleβ€”they don't require Trusted Types because they're inherently safe.

Validation performance:

// For frequently-used values, cache the trusted result
const trustedCache = new Map();

function getCachedTrustedHTML(key, generator) {
  if (!trustedCache.has(key)) {
    trustedCache.set(key, htmlPolicy.createHTML(generator()));
  }
  return trustedCache.get(key);
}

// Use for static templates
const header = getCachedTrustedHTML('header', 
  () => '<header><h1>My App</h1></header>'
);

Best Practice 3: Gradual Migration Strategy

πŸ€” Did you know? Google's own migration to Trusted Types across all products took over two years, with careful planning at each stage.

Recommended migration approach:

Phase 1: Report-Only Mode
   ↓
   Monitor violations without breaking functionality
   ↓
Phase 2: Create Named Policies
   ↓
   Build secure policies for new code
   ↓
Phase 3: Refactor High-Risk Areas
   ↓
   Convert authentication, user profiles, messages
   ↓
Phase 4: Restrictive Default Policy
   ↓
   Handle remaining legacy code carefully
   ↓
Phase 5: Enforce Mode
   ↓
   Enable strict enforcement, remove default policy

CSP headers for each phase:

## Phase 1: Report-Only
Content-Security-Policy-Report-Only: require-trusted-types-for 'script'; report-uri /csp-reports

## Phase 5: Enforcement
Content-Security-Policy: require-trusted-types-for 'script'; trusted-types sanitized-html app-templates; report-uri /csp-reports

Summary

You now understand the critical difference between implementing Trusted Types correctly versus creating the illusion of security. Before this section, you might have thought enabling Trusted Types was sufficientβ€”now you recognize that policy design is where security happens.

What you've learned:

βœ… Default policies are dangerous unless extremely restrictiveβ€”they're convenience features that often become security holes

βœ… Policy functions must sanitize, not just wrapβ€”the type system enforces calling patterns, but sanitization logic determines actual security

βœ… Type selection mattersβ€”using TrustedHTML for script sources or TrustedScriptURL for content will fail or create bypasses

βœ… Centralization enables auditabilityβ€”scattered policies are security review nightmares

βœ… Performance optimization is possibleβ€”cache trusted values, prefer safe DOM APIs, and avoid hot path policy calls

⚠️ Critical reminders:

  • A permissive policy is worse than no policyβ€”it creates false confidence
  • Every policy function is part of your security perimeter and needs rigorous testing
  • Migration should be gradual with monitoringβ€”rushing leads to bypass patterns

Practical next steps:

πŸ”§ Audit existing policies: Review any policies in your codebase. Do they actually sanitize, or just wrap inputs? Are they centralized?

πŸ”§ Set up violation monitoring: Even in enforcement mode, monitor CSP reports to catch attempted attacks and implementation bugs

πŸ”§ Build a policy testing suite: Write unit tests that verify your policies reject malicious inputs like <img src=x onerror=alert(1)>, javascript:alert(1), and data:text/html,<script>alert(1)</script>

With these best practices, you're equipped to implement Trusted Types as a genuine security layer rather than a compliance checkbox. The difference between secure and insecure implementations often comes down to these seemingly small detailsβ€”but in security, details are everything.