dompurify
DOMPurify takes a string of untrusted HTML and gives you back a string with everything dangerous removed: script tags, event handler attributes, javascript: URLs, and the long tail of parser tricks that turn markup into code execution. It does this by parsing the input with the browser's own HTML parser and walking the resulting tree against an allow-list, which is why it handles malformed input the same way the browser will. It covers HTML, SVG, and MathML by default, has no dependencies, and is written and maintained by the security researchers at Cure53 who also publish the attack research behind it. On the server it needs a DOM implementation such as jsdom.
If you must render untrusted HTML in a browser, this is the library, and it is the rare dependency where the maintainers are the same people publishing the attack research. Treat upgrades as security patches, never post-process the output, and keep jsdom current if you run it server-side.
Use it if
- You render HTML you did not author (comments, rich text editor output, email bodies, third-party feeds) into innerHTML or dangerouslySetInnerHTML
- You need the sanitizer to agree with the browser's parser, including mutation XSS cases where naive regex or string filters see different markup than the browser does
- You want a configurable allow-list: ALLOWED_TAGS, ALLOWED_ATTR, USE_PROFILES, and hooks to add rules like forcing rel=noopener on links
- You are adopting Trusted Types and need a sanitizer that can act as the createHTML implementation for your policy
- You can avoid rendering untrusted HTML at all: escaping to text, or rendering markdown through a constrained AST that never emits raw HTML, removes the whole attack surface. A sanitizer is a filter you must keep patched forever, and the bypass history is long
- You pin dependencies and rarely upgrade. An old DOMPurify is a known-bypassable DOMPurify; the value of this library comes almost entirely from tracking its releases
- You are on the server without jsdom. You need a real DOM, jsdom is a heavy dependency, older jsdom versions have led to XSS even when DOMPurify behaved correctly, and the maintainers state plainly that pairing it with happy-dom is not safe. sanitize-html is designed for Node and does not need a DOM
- Your pipeline touches the HTML after sanitizing: running the output through a template engine, a string replace, or another library can reintroduce exactly what was removed, and this is the most common way teams sanitize and still get hit
- Your backend is not JavaScript. Sanitizing in the browser only protects the render you control, so a Python or Go service still needs its own sanitizer at write time
- Your requirement is a fixed, tiny subset such as bold, italic, and links. Modern browsers ship a native HTML sanitizer (Element.setHTML), and if your support matrix allows it you may not need a dependency
Setup reality
npm install dompurify and call DOMPurify.sanitize(dirty) in a browser and you are done: no dependencies, no build step, ESM and CJS both exported, types included. Server-side is where the work is. You install jsdom too, build a window, and pass it to the factory, and you must keep jsdom current because a stale DOM implementation can hand DOMPurify markup the real browser would parse differently. Bundlers occasionally trip on the dual purify.es.mjs and purify.cjs.js exports in mixed ESM and CJS test setups, and the isomorphic-dompurify wrapper exists mainly to paper over that. If your page sets a strict Content Security Policy with trusted-types, DOMPurify's attempt to create its own internal policy named dompurify gets blocked and logs a violation until you either allow that name or pass TRUSTED_TYPES_POLICY: null. Finally, defaults allow SVG and MathML; most apps want USE_PROFILES: { html: true } instead.
Patterns
Sanitize a string of HTMLbasic-sanitize
import DOMPurify from 'dompurify';
const clean = DOMPurify.sanitize(dirty);
container.innerHTML = clean;The default config allows HTML, SVG, and MathML. Assign the result immediately; anything that edits the string afterwards can undo the sanitization.
Allow plain HTML but no SVG or MathMLhtml-only-profile
const clean = DOMPurify.sanitize(dirty, {
USE_PROFILES: { html: true },
});Most apps never need SVG or MathML, and both have carried bypass classes of their own. USE_PROFILES overrides ALLOWED_TAGS, so do not pass the two together.
Restrict to a small set of tags and attributesstrict-allowlist
const clean = DOMPurify.sanitize(dirty, {
ALLOWED_TAGS: ['b', 'i', 'em', 'strong', 'a', 'p', 'ul', 'ol', 'li', 'code'],
ALLOWED_ATTR: ['href', 'title'],
});An explicit allow-list is the strongest configuration available here. Note that KEEP_CONTENT defaults to true, so text inside a removed tag survives even though the tag does not.
Render sanitized HTML in Reactreact-inner-html
import DOMPurify from 'dompurify';
function Comment({ html }) {
const clean = DOMPurify.sanitize(html, { USE_PROFILES: { html: true } });
return <div dangerouslySetInnerHTML={{ __html: clean }} />;
}Sanitize at render time, not once at fetch time into state that other code may mutate. During server rendering this same component needs a DOM, so guard it or use the isomorphic wrapper.
Run it in Node with jsdomserver-side-jsdom
import { JSDOM } from 'jsdom';
import DOMPurify from 'dompurify';
const window = new JSDOM('').window;
const purify = DOMPurify(window);
const clean = purify.sanitize('<img src=x onerror=alert(1)>');Create the window once and reuse the instance; building a JSDOM per request is slow. Keep jsdom on its latest version, and do not substitute happy-dom, which the maintainers say is not safe for this.
Force rel and target on every linksafe-links-hook
DOMPurify.addHook('afterSanitizeAttributes', (node) => {
if (node.tagName === 'A' && node.hasAttribute('href')) {
node.setAttribute('target', '_blank');
node.setAttribute('rel', 'noopener noreferrer');
}
});
const clean = DOMPurify.sanitize(dirty);Hooks are global to the instance and stack up, so register them once at module load. Use removeHook or removeAllHooks in tests, otherwise hooks leak between cases.
Get a DOM fragment instead of a stringreturn-dom-fragment
const fragment = DOMPurify.sanitize(dirty, {
RETURN_DOM_FRAGMENT: true,
});
container.replaceChildren(fragment);Skipping the string round trip removes a step where post-processing could reintroduce a payload, and it avoids a second parse of markup that was just parsed.
Back a Trusted Types policy with DOMPurifytrusted-types-policy
window.trustedTypes.createPolicy('my-organization', {
createHTML: (input) =>
DOMPurify.sanitize(input, { TRUSTED_TYPES_POLICY: null }),
});createHTML must receive a plain string, so keep RETURN_TRUSTED_TYPE off here. TRUSTED_TYPES_POLICY: null stops DOMPurify creating its own internal dompurify policy, which a strict trusted-types CSP would block anyway. Never hand your own wrapping policy back to DOMPurify: that recursion throws by design.
Set config once for the whole apppersistent-config
DOMPurify.setConfig({
USE_PROFILES: { html: true },
ALLOWED_ATTR: ['href', 'title', 'class'],
});
DOMPurify.sanitize(dirty); // uses the persisted config
// later, back to defaults
DOMPurify.clearConfig();There is only one active persistent config, and while it is set, config objects passed to sanitize() are ignored entirely. That silent override surprises people who mix both styles.
Extend the allow-list for your own markupallow-custom-elements
const clean = DOMPurify.sanitize(dirty, {
ADD_TAGS: ['my-widget'],
ADD_ATTR: ['data-widget-id'],
});ADD_TAGS and ADD_ATTR extend the defaults rather than replacing them. Read the wiki list of attributes to think twice about first: allowing style, srcset, or anything URL-shaped widens the attack surface in non-obvious ways.
Control which URL schemes surviveuri-protocols
const clean = DOMPurify.sanitize(dirty, {
ALLOWED_URI_REGEXP:
/^(?:(?:https?|mailto):|[^a-z]|[a-z+.\-]+(?:[^a-z+.\-:]|$))/i,
});The default already blocks javascript: and data: in most positions while permitting http, https, ftp, mailto, tel, callto, sms, cid, xmpp, and matrix. Narrowing it is fine; setting ALLOW_UNKNOWN_PROTOCOLS: true to make one link type work re-opens the hole.
See what got stripped while debugginginspect-removed
const clean = DOMPurify.sanitize(dirty);
console.log(DOMPurify.removed); // [{ element: ... }, { attribute: ... }]The README is explicit that this is a debugging aid only. Do not branch security decisions such as rejecting a submission on the contents of DOMPurify.removed.
Alternatives
| Package | Registry | Pick it when |
|---|---|---|
| sanitize-html | npm | You sanitize on a Node server and would rather have a parser-based library built for that job than run jsdom to fake a browser |
| isomorphic-dompurify | npm | You need the same sanitizer call to work in the browser and in server rendering without writing the jsdom wiring yourself |
| xss | npm | You want a small allow-list filter with no DOM requirement and can accept weaker coverage of parser edge cases |