sanitize-html
sanitize-html takes a string of untrusted HTML and returns a string containing only the tags, attributes, classes, styles and URL schemes you explicitly allowed. It runs on htmlparser2 rather than a DOM, so it works in plain Node with no jsdom and no browser, which is what makes it the usual server-side answer for rich text coming out of CKEditor, TinyMCE or a Markdown pipeline. Everything it keeps gets escaped properly, disallowed tags are dropped while their text is preserved by default, href and src values are checked against an allowed scheme list, and inline styles are parsed with postcss so you can allow specific CSS properties by regular expression. It was written for ApostropheCMS and now lives inside that project's monorepo.
The pragmatic default for cleaning rich text on a Node server, with the most detailed per-tag policy options of anything in this space. Stay close to the default allowlist, subscribe to its advisories, and reach for DOMPurify instead the moment the sanitizing needs to happen in a browser.
Use it if
- You accept rich text from users on a Node server and need to store a cleaned version, which is the only place sanitizing actually counts because a browser can never be trusted to have done it
- You need per-tag control rather than a fixed policy: allowedAttributes keyed by tag, allowedClasses with prefix wildcards or regexes, allowedStyles as CSS property regexes, and allowedSchemesByTag
- You want to rewrite as well as strip: transformTags can turn ol into ul or inject rel="nofollow" on every anchor, and exclusiveFilter can drop empty links while textFilter rewrites text nodes
- You are pasting from Word or another editor and want the pile of junk markup and inline CSS reduced to something your templates can render, which is the case the library was originally built for
- You are sanitizing in a browser or an edge runtime. It is about 60 KB gzipped with postcss along for the ride, and the README itself argues against browser sanitizing; DOMPurify is smaller there and reuses the browser's own HTML parser instead of approximating one
- You plan to allow exotic tags such as svg, math, xmp, textarea or option. Every security release between March and July 2026 was a bypass in exactly that territory, caused by htmlparser2 tokenizing differently from a real HTML5 parser. The default allowlist was unaffected each time, which tells you where the risk actually lives
- You want first-class TypeScript. The package ships no types and the maintainers state there is no plan to add them, so you depend on the community @types/sanitize-html package and TypeScript 4.5 or newer
- You cannot run Node 22.12.0 or newer. Version 2.17.6 raised the engines floor mid-patch-series because htmlparser2 went ESM-only in v11, so a routine security upgrade can block on your runtime
- You want the option list to be self-explanatory. allowedStyles does nothing unless you also allow the style attribute in allowedAttributes, a regex without ^ and $ silently matches substrings, and setting parseStyleAttributes: false together with allowedStyles throws. None of these fail loudly at config time
- You want a focused upstream. Development moved into the apostrophecms/apostrophe monorepo and the standalone repository was archived in February 2026, so the star count, the issue tracker and the release cadence you see are now those of a whole CMS
Setup reality
npm install sanitize-html pulls seven pure JavaScript dependencies, postcss and htmlparser2 among them, with no native build. The package is CommonJS with a single default export, so under TypeScript you need esModuleInterop enabled or the import * as sanitizeHtml form, plus npm install -D @types/sanitize-html because no types ship. Since 2.17.6 the engines field requires Node >= 22.12.0, which is the first 22.x that can require() an ES module, and installers with engine-strict will refuse older runtimes. There is no prebuilt browser bundle any more; 2.x expects you to run it through your own bundler, and in the browser you may have to set parseStyleAttributes: false because of an open postcss issue. Watch the defaults too: the default allowedTags list is generous but excludes img and iframe, and the source default allowedSchemes is http, https, ftp, mailto and tel even though one section of the README still lists only four.
Patterns
Clean a string with the built-in allowlistsanitize-with-defaults
import sanitizeHtml from 'sanitize-html';
const clean = sanitizeHtml(dirty);
// <script>alert(1)</script> -> ''
// <img src=x onerror=alert(1)> -> ''The default allowlist covers headings, lists, tables, links and inline formatting but not img, iframe, script or style. Comments are always dropped and all text is escaped, so ampersands come back as &.
Define a tight policy of your owncustom-allowlist
const clean = sanitizeHtml(dirty, {
allowedTags: ['b', 'i', 'em', 'strong', 'a', 'p', 'br'],
allowedAttributes: {
a: ['href', 'title', 'target', 'rel']
},
allowedSchemes: ['http', 'https', 'mailto']
});Passing allowedTags replaces the default list entirely rather than adding to it. If you want no tags at all you must pass allowedTags: [] and allowedAttributes: {}, because omitting them means the defaults apply.
Add one tag to the default setextend-defaults
const clean = sanitizeHtml(dirty, {
allowedTags: sanitizeHtml.defaults.allowedTags.concat(['img']),
allowedAttributes: {
...sanitizeHtml.defaults.allowedAttributes,
img: ['src', 'alt', 'width', 'height', 'loading']
}
});sanitizeHtml.defaults exposes the built-in configuration so you can extend instead of retyping it. Spread allowedAttributes rather than assigning it, or you drop the default a: ['href', 'name', 'target'] entry along the way.
Reduce HTML to plain textstrip-all-markup
const text = sanitizeHtml(dirty, {
allowedTags: [],
allowedAttributes: {},
disallowedTagsMode: 'discard'
});With discard the tags go and the text inside them stays. Use completelyDiscard instead if you want the contents of disallowed tags thrown away too. Neither inserts whitespace, so <p>a</p><p>b</p> becomes ab.
Permit specific CSS classes and inline stylesallowed-classes-and-styles
const clean = sanitizeHtml(dirty, {
allowedTags: ['p', 'code', 'span'],
allowedAttributes: {
p: ['style'],
span: ['class']
},
allowedClasses: {
code: ['language-*', 'lang-*'],
'*': ['highlight']
},
allowedStyles: {
'*': {
color: [/^#(0x)?[0-9a-f]+$/i],
'text-align': [/^left$/, /^right$/, /^center$/],
'font-size': [/^\d+(?:px|em|%)$/]
}
}
});allowedStyles never runs unless style is also listed in allowedAttributes for that tag. Anchor every regex with ^ and $: a pattern like /red/ matches url(javascript:...) red too. allowedClasses implies the class attribute is allowed on that tag.
Allow embeds from named hosts onlyiframe-allowlist
const clean = sanitizeHtml(dirty, {
allowedTags: ['p', 'iframe'],
allowedAttributes: {
iframe: ['src', 'width', 'height', 'allowfullscreen']
},
allowedIframeHostnames: ['www.youtube.com', 'player.vimeo.com'],
allowedIframeDomains: ['zoom.us'],
allowIframeRelativeUrls: false
});Hostnames must match exactly, so www.youtube.com does not cover youtube.com. allowedIframeDomains matches any subdomain depth. A src that fails the check is stripped and you are left with an empty iframe element, not a removed one.
Rewrite tags and force attributestransform-tags
const clean = sanitizeHtml(dirty, {
allowedTags: ['a', 'ul', 'li'],
allowedAttributes: { a: ['href', 'target', 'rel'] },
transformTags: {
ol: 'ul',
a: (tagName, attribs) => ({
tagName: 'a',
attribs: { ...attribs, target: '_blank', rel: 'noopener noreferrer' }
})
}
});Attributes you add in a transform still have to be in allowedAttributes or they are stripped straight back out. sanitizeHtml.simpleTransform('ul', {class: 'foo'}) covers the common case and merges with existing attributes unless you pass false as the third argument.
Drop empty elements and rewrite text nodesfilter-tags-and-text
const clean = sanitizeHtml(dirty, {
exclusiveFilter: frame =>
frame.tag === 'a' && !frame.attribs.href ? 'excludeTag' : frame.tag === 'p' && !frame.text.trim(),
textFilter: (text, tagName) => (tagName === 'a' ? text : text.replace(/\.\.\./g, '…'))
});Returning true from exclusiveFilter removes the tag and everything in it; returning the string 'excludeTag' removes only the tag and keeps the content. The text handed to textFilter is already escaped, so returning raw < there reopens a hole you closed.
Show disallowed markup instead of deleting itescape-instead-of-discard
// <disallowed>content</disallowed> -> <disallowed>content</disallowed>
const clean = sanitizeHtml(dirty, {
allowedTags: ['b', 'i'],
disallowedTagsMode: 'escape',
preserveEscapedAttributes: true
});The four modes are discard (default), completelyDiscard, escape and recursiveEscape. recursiveEscape escapes nested allowed tags as well. Attributes are dropped when escaping unless you set preserveEscapedAttributes, added in 2.17.0.
Allow attribute prefixes and global attributeswildcard-attributes
const clean = sanitizeHtml(dirty, {
allowedAttributes: {
a: ['href', 'data-*'],
'*': ['id', 'lang', 'dir'],
iframe: [
{ name: 'sandbox', multiple: true, values: ['allow-scripts', 'allow-same-origin'] }
]
}
});The '*' tag key applies the listed attributes to every allowed tag, and data-* allows any attribute with that prefix. The object form restricts an attribute to specific values; with multiple: true several space-separated values may pass, otherwise the value must match exactly one.
Guard against nesting bombs and stray editor outputlimit-depth-and-boundary
const clean = sanitizeHtml(dirty, {
nestingLimit: 10,
enforceHtmlBoundary: true,
nonTextTags: ['style', 'script', 'textarea', 'option', 'xmp', 'noscript']
});nestingLimit strips tags deeper than the limit as if they were disallowed, which blunts pathological inputs from pasted documents. enforceHtmlBoundary throws away everything outside <html></html>. If you override nonTextTags you own the whole list, so keep style and script in it.
Use it from TypeScripttypescript-setup
// npm install sanitize-html
// npm install -D @types/sanitize-html
import sanitizeHtml from 'sanitize-html'; // needs esModuleInterop: true
// otherwise: import * as sanitizeHtml from 'sanitize-html';
const options: sanitizeHtml.IOptions = {
allowedTags: ['b', 'i', 'a'],
allowedAttributes: { a: ['href'] }
};
export const clean = (dirty: string): string => sanitizeHtml(dirty, options);The package itself is untyped and the maintainers say that will not change, so the types come from DefinitelyTyped and lag new options. TypeScript 4.5 is the floor because of the htmlparser2 types the definitions depend on.
Alternatives
| Package | Registry | Pick it when |
|---|---|---|
| dompurify | npm | You are sanitizing in the browser or already run jsdom on the server and want a real DOM parser doing the work |
| xss | npm | You want a smaller dependency-light allowlist filter for Node and can live with a narrower option surface |
| hast-util-sanitize | npm | Your content already runs through unified, remark or rehype and you would rather sanitize the syntax tree than a string |