Sanitizing Rich Text With DOMPurify
DOMPurify is the industry-standard JavaScript library for HTML sanitization, maintained by Cure53 and actively used by major platforms (Discord, Slack, Mozilla). It parses HTML, removes malicious code (script tags, event handlers, dangerous protocols), and returns clean markup safe for display. DOMPurify is essential whenever you accept user-authored HTML—rich-text editors, Markdown with embedded HTML, or third-party content. In 2026, DOMPurify is part of OWASP's recommended defense stack and is integrated into security tools like npm Snyk.
Why DOMPurify Is Necessary
React's automatic escaping prevents <script> tags and event handlers from executing when you render text values. However, when you intentionally want to display formatted HTML (bold, italics, links, embedded videos), escaping defeats the purpose. You must accept HTML tags, which means you cannot escape them. DOMPurify strips malicious tags while preserving safe formatting.
Definition: DOMPurify is a DOM-only XSS sanitizer that parses HTML, applies a configurable whitelist, and removes scripts, event handlers, and dangerous protocols, returning clean HTML safe for display.
When DOMPurify Is Required
- User comments on blog posts: Users might paste formatted text from Word or Google Docs.
- Rich-text editor output: Users author HTML with bold, italic, links, and embedded media.
- Markdown to HTML conversion: If Markdown includes raw HTML blocks.
- Third-party embeds: Display HTML from semi-trusted sources (e.g., Slack, Stripe).
- User profile bios: Allow limited HTML formatting in user profiles.
When DOMPurify Is NOT Required
- Plain text display: If you never expect or accept HTML, React's automatic escaping is sufficient.
- User input bound to attributes: URL attributes (
href,src) are safer with React's built-in protocol blocking. - Data from fully trusted sources: Your own backend, official APIs. (Still sanitize for defense-in-depth.)
Installing and Configuring DOMPurify
npm install dompurify
# TypeScript support (optional, recommended)
npm install --save-dev @types/dompurify
Basic Usage
import DOMPurify from 'dompurify';
function CommentDisplay({ comment }) {
// Sanitize with default config (whitelist common formatting tags)
const clean = DOMPurify.sanitize(comment.html);
return <div dangerouslySetInnerHTML={{ __html: clean }} />;
}
// Input: '<p>Hello <b>world</b><script>alert(1)</script></p>'
// Output: '<p>Hello <b>world</b></p>'
// The script tag is removed, but <p> and <b> are preserved.
Advanced Configuration
DOMPurify accepts a config object to customize which tags and attributes are allowed:
import DOMPurify from 'dompurify';
function RichTextDisplay({ html }) {
const config = {
ALLOWED_TAGS: ['p', 'br', 'strong', 'em', 'a', 'ul', 'ol', 'li', 'blockquote', 'h2', 'h3'],
ALLOWED_ATTR: ['href', 'title', 'target'],
ALLOW_UNKNOWN_PROTOCOLS: false, // Prevent javascript: and data: URLs
KEEP_CONTENT: true, // Keep text inside removed tags
};
const clean = DOMPurify.sanitize(html, config);
return <div dangerouslySetInnerHTML={{ __html: clean }} />;
}
// Config explained:
// - ALLOWED_TAGS: only these HTML tags are preserved
// - ALLOWED_ATTR: only these attributes are preserved (href, title on <a>, etc.)
// - ALLOW_UNKNOWN_PROTOCOLS: false blocks javascript:, data:, vbscript:
// - KEEP_CONTENT: if a tag is not in ALLOWED_TAGS but its content is safe, keep the text
Whitelisting Strategies
Whitelisting (allowing specific safe tags) is safer than blacklisting (blocking dangerous tags) because new attack vectors are discovered constantly.
Conservative Whitelist (Plain Formatting Only)
const strictConfig = {
ALLOWED_TAGS: ['b', 'i', 'em', 'strong', 'a', 'br'],
ALLOWED_ATTR: ['href', 'title'],
};
const clean = DOMPurify.sanitize(userHtml, strictConfig);
// Allows: bold, italic, links, line breaks
// Blocks: images, videos, scripts, styles, forms, iframes
Use case: User comments, social media bios, short-form user input.
Moderate Whitelist (Formatting + Media)
const moderateConfig = {
ALLOWED_TAGS: [
'p', 'br', 'strong', 'em', 'a', 'u', 's', // Text formatting
'h1', 'h2', 'h3', // Headings
'ul', 'ol', 'li', // Lists
'blockquote', // Quotes
'img', // Images
],
ALLOWED_ATTR: ['href', 'title', 'src', 'alt', 'width', 'height'],
ALLOW_UNKNOWN_PROTOCOLS: false,
};
const clean = DOMPurify.sanitize(userHtml, moderateConfig);
// Allows: text formatting, lists, images, links
// Blocks: scripts, styles, forms, iframes, embedded videos
Use case: Blog post content, documentation, user-authored articles.
Permissive Whitelist (Everything Except Dangerous)
const permissiveConfig = {
ALLOWED_TAGS: false, // Allow all tags (DOMPurify will still filter script, style, form)
ALLOWED_ATTR: false, // Allow all attributes except event handlers
ALLOW_UNKNOWN_PROTOCOLS: false,
};
const clean = DOMPurify.sanitize(userHtml, permissiveConfig);
// Allows: most HTML tags and attributes
// Blocks: <script>, <style>, <form>, event handlers, javascript: URLs
Use case: Migrating legacy HTML content, third-party HTML from trusted sources. (Rare and high-risk.)
Important: Even with ALLOWED_TAGS: false, DOMPurify maintains a hardcoded blacklist of inherently dangerous tags (<script>, <style>, <form>, <iframe>, <meta>, etc.) for safety.
Real-World Example: Blog Post with Images
import React, { useState, useEffect } from 'react';
import DOMPurify from 'dompurify';
function BlogPost({ postId }) {
const [post, setPost] = useState(null);
const [error, setError] = useState('');
useEffect(() => {
// Fetch blog post from API
fetch(`/api/posts/${postId}`)
.then((res) => {
if (!res.ok) throw new Error('Post not found');
return res.json();
})
.then((data) => {
// Validate response structure
if (typeof data.title !== 'string' || typeof data.content !== 'string') {
throw new Error('Invalid post format');
}
setPost(data);
})
.catch((err) => setError(err.message));
}, [postId]);
if (error) return <div className="error">Error: {error}</div>;
if (!post) return <div>Loading...</div>;
// Sanitize the post content
const config = {
ALLOWED_TAGS: [
'p', 'br', 'strong', 'em', 'a', 'h2', 'h3',
'ul', 'ol', 'li', 'blockquote', 'img', 'figure', 'figcaption',
],
ALLOWED_ATTR: ['href', 'title', 'src', 'alt', 'width', 'height'],
ALLOW_UNKNOWN_PROTOCOLS: false,
};
const cleanContent = DOMPurify.sanitize(post.content, config);
return (
<article>
<h1>{post.title}</h1> {/* Title as text; React escapes it automatically */}
<div className="content" dangerouslySetInnerHTML={{ __html: cleanContent }} />
</article>
);
}
export default BlogPost;
Sanitizing Markdown Output
When you convert Markdown to HTML, DOMPurify ensures the result is safe:
import React from 'react';
import { marked } from 'marked';
import DOMPurify from 'dompurify';
function MarkdownPreview({ markdown }) {
// Convert Markdown to HTML
const rawHtml = marked(markdown);
// Sanitize the HTML (remove scripts, events, etc.)
const clean = DOMPurify.sanitize(rawHtml, {
ALLOWED_TAGS: [
'p', 'br', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
'strong', 'em', 'a', 'u', 's', 'code', 'pre',
'ul', 'ol', 'li', 'blockquote', 'img',
],
ALLOWED_ATTR: ['href', 'title', 'src', 'alt'],
ALLOW_UNKNOWN_PROTOCOLS: false,
});
return <div dangerouslySetInnerHTML={{ __html: clean }} />;
}
export default MarkdownPreview;
// Input Markdown: [link](javascript:alert(1))
// marked() converts to: <a href="javascript:alert(1)">link</a>
// DOMPurify filters out the javascript: protocol: <a href="">link</a>
Handling Edge Cases
Allowing Embedded Videos (iframe with src)
By default, DOMPurify blocks <iframe> because it can embed malicious content. To allow specific iframe sources (e.g., YouTube), add a custom hook:
import DOMPurify from 'dompurify';
// Register a hook to allow YouTube iframes
DOMPurify.addHook('uponSanitizingElement', (node, data, config) => {
if (node.tagName === 'IFRAME') {
const src = node.getAttribute('src');
// Only allow iframes from youtube.com or youtu.be
if (src && (src.includes('youtube.com') || src.includes('youtu.be'))) {
return; // Allow this iframe
}
// Remove the iframe if it's not from YouTube
node.parentElement.removeChild(node);
}
});
const clean = DOMPurify.sanitize(userHtml, {
ALLOWED_TAGS: ['iframe'],
ALLOWED_ATTR: ['src', 'width', 'height', 'allowfullscreen'],
});
Relative URLs in href and src
DOMPurify preserves relative URLs (e.g., /images/photo.jpg) by default. Be careful if your site structure changes:
// Safe: DOMPurify allows and preserves relative URLs
const clean = DOMPurify.sanitize('<a href="/posts/123">Post</a>');
// Output: <a href="/posts/123">Post</a>
// If you want to enforce absolute URLs:
const config = {
ALLOWED_TAGS: ['a'],
ALLOWED_ATTR: ['href'],
};
const customSanitized = DOMPurify.sanitize(userHtml, config);
// Then manually prepend domain if needed (in your app logic):
// if (url.startsWith('/')) url = 'https://example.com' + url;
Performance Considerations
DOMPurify parses HTML at runtime, which has a small performance cost. For large-scale applications:
- Cache sanitized HTML: If the same user-generated content is displayed to many users, sanitize once and cache the result.
function CommentDisplay({ comment }) {
// Cache: If comment.id is the same, don't re-sanitize
const cleanContent = useMemo(
() => DOMPurify.sanitize(comment.html),
[comment.id] // Only re-sanitize if the comment ID changes
);
return <div dangerouslySetInnerHTML={{ __html: cleanContent }} />;
}
- Sanitize on the backend: For scalability, sanitize once on the server and store the clean HTML.
// Backend (Node.js)
const DOMPurify = require('isomorphic-dompurify'); // Server-side version
app.post('/comments', (req, res) => {
const { text } = req.body;
const cleanText = DOMPurify.sanitize(text);
// Store the clean HTML in the database
db.comments.insert({ text: cleanText });
res.json({ success: true });
});
Comparison: Sanitization Libraries
| Library | Features | Maintenance | Use Case |
|---|---|---|---|
| DOMPurify | HTML parsing, configurable whitelist, hooks | Active (Cure53, 2026) | General HTML sanitization, React apps |
| sanitize-html | HTML parsing, attribute filtering, advanced config | Moderate | Backend sanitization (Node.js) |
| xss | Fast, lightweight, smaller bundle | Active | Simple XSS prevention, attributes only |
| Bleach (Python) | HTML sanitization, Markdown support | Active | Python backends, Django integration |
Data point: According to npm statistics (2026), DOMPurify has 15M+ weekly downloads and is the de facto standard for browser-based HTML sanitization.
Key Takeaways
- DOMPurify sanitizes HTML by parsing it and applying a whitelist, removing dangerous tags and attributes.
- Whitelist, don't blacklist: Specify which tags are safe; DOMPurify removes everything else.
- Always configure ALLOWED_TAGS and ALLOWED_ATTR based on your use case (strict for user comments, moderate for blog posts).
- Block dangerous protocols: Set
ALLOW_UNKNOWN_PROTOCOLS: falseto preventjavascript:anddata:URLs. - Pair with
dangerouslySetInnerHTML: Sanitize first, then pass todangerouslySetInnerHTML(Article 3). - Cache or sanitize on the backend: For high-traffic sites, sanitize once and reuse the result.
Frequently Asked Questions
Is DOMPurify slow?
DOMPurify's performance depends on HTML size. For typical comment lengths (under 10KB), sanitization takes <5ms. For large content (100KB+), it may take 50-200ms. Cache sanitized content or sanitize on the backend for high-traffic sites to avoid client-side slowdown.
Can I use DOMPurify with Markdown?
Yes, convert Markdown to HTML first (using marked or remark), then sanitize the HTML with DOMPurify. See the "Sanitizing Markdown Output" example above. Alternatively, use a Markdown library with built-in sanitization like react-markdown with a sanitizer plugin.
What if I want to allow inline styles?
Use the config:
const config = {
ALLOWED_TAGS: ['p', 'div'],
ALLOWED_ATTR: ['style'],
ALLOW_UNKNOWN_PROTOCOLS: false,
};
However, inline styles can be used for denial-of-service attacks (e.g., style="width: 999999999px") or data exfiltration (CSS injection). Use sparingly and consider CSS sandboxing libraries like DOMPurify's hook system.
Does DOMPurify protect against zero-day exploits?
DOMPurify removes known attack vectors and is actively maintained by security experts. However, no sanitizer is 100% future-proof against undiscovered vulnerabilities. Use DOMPurify as one layer in a defense-in-depth strategy (input validation, CSP headers, security audits). As of 2026, DOMPurify has a strong track record and is recommended by OWASP.
Can I test my whitelist?
Yes, write unit tests:
import DOMPurify from 'dompurify';
const config = { ALLOWED_TAGS: ['p', 'strong'] };
test('should remove script tags', () => {
const result = DOMPurify.sanitize('<p>Hello <script>alert(1)</script></p>', config);
expect(result).toBe('<p>Hello </p>');
});
test('should preserve strong tags', () => {
const result = DOMPurify.sanitize('<p><strong>Bold</strong></p>', config);
expect(result).toBe('<p><strong>Bold</strong></p>');
});