nh3
nh3 takes a string of untrusted HTML and gives you back a string of HTML that is safe to put on a page. It is a thin Python binding over ammonia, a Rust crate that parses the input with a real HTML5 parser and then rebuilds the output from an allowlist of tags, attributes, and URL schemes. Anything not on the list is dropped. Because it re-serializes from a parse tree rather than pattern-matching on the text, the classic bypasses that beat regex-based filters do not apply: malformed nesting, weird casing, null bytes, and broken quoting all get normalized before the allowlist runs. The API is four functions and one class. nh3.clean(html) sanitizes with sensible defaults. nh3.escape(html) escapes everything so the input renders as literal text. nh3.is_html(s) tells you whether a string contains markup at all. nh3.Cleaner(...) is the same as clean but with the configuration compiled once so you can reuse it. There are no Python dependencies at all; the shipped wheels contain compiled Rust.
The right default for sanitizing user HTML in Python now that bleach is deprecated: a real HTML5 parser, an allowlist rebuild, and roughly twenty times bleach's throughput for a two-line change. Check ALLOWED_TAGS against your own threat model first, because the defaults are tuned for rich text rather than for a comment box.
Use it if
- You accept HTML from users, whether that is a comment box, a rich text editor, or rendered Markdown, and you have to put it back on a page inside somebody else's session
- You are moving off bleach, which Mozilla deprecated and stopped maintaining in 2023: nh3 covers the same clean() job and the README measures 138 microseconds against bleach's 2.85 milliseconds on the same input
- Sanitizing is on a hot path, such as rendering a feed of hundreds of user-authored items per request, where a pure Python filter shows up in your profile
- You need fine-grained policy rather than on or off: per-tag attribute allowlists, per-tag allowed class names, allowed values for a specific attribute, a style property allowlist, and a callback that can rewrite or drop any attribute
- You want no dependency tree. nh3 declares zero Python requirements, which matters in a base image or a library that other people install
- You need bleach.linkify. nh3 has no equivalent, so turning bare URLs and @mentions in plain text into anchor tags is code you write yourself, and that is the single most common reason a bleach migration stalls halfway
- You are sanitizing whole documents. ammonia is a fragment sanitizer; html, head, and body are not in the default allowlist, so feeding it a full page gives you the body contents with the shell stripped and no warning that it happened
- You need to parse or query the HTML rather than clean it. There is no tree, no node access, no text extraction, no attribute reading. If you also need to walk the document, you are installing lxml or BeautifulSoup anyway and lxml-html-clean is already there
- You install from source. There is no pure Python fallback: 27 wheels cover the mainstream targets, and anything outside them builds the Rust crate, which means a toolchain, maturin, and several minutes in your CI
- You expect bleach's parameter names to carry over. clean(tags=...) takes a set rather than a list, styles= is gone in favour of filter_style_properties, strip= does not exist, and clean_content_tags is a concept bleach did not have. A copy-pasted call signature usually fails loudly, but tags=['b'] passing a list where a set is expected is the kind of thing that slips through
- You want a large project behind it. This is 389 stars and effectively one maintainer, with 4 open issues out of 5 open issues and PRs. The security-critical logic lives upstream in ammonia, which is the reassuring part, but a nh3-specific binding bug has one person to fix it
- You are sanitizing SVG or MathML. Neither is in the default allowlist, and allowing svg wholesale re-opens script vectors that the default policy exists to close
Setup reality
pip install nh3 and there is nothing to configure: no Python dependencies, no compiler on the common platforms, abi3 wheels covering CPython 3.8 and up plus PyPy for Linux, macOS, and Windows on x86_64 and arm64. The friction is elsewhere. First, the default policy is deliberately permissive because it is meant for user-authored rich text: a, img, table, blockquote, code, and around sixty other tags are allowed out of the box, and if your comment box should only accept bold and links then you must pass tags={'b','a'} yourself. Read nh3.ALLOWED_TAGS before you decide the defaults match your threat model. Second, nh3.clean(html, tags=..., attributes=...) recompiles the policy on every single call, so a loop over a thousand comments builds the allowlist a thousand times; build a Cleaner once at module level instead. Third, when you customize ALLOWED_ATTRIBUTES you have to deepcopy it, because it is a plain dict of sets and mutating it in place changes the default policy for the whole process. Fourth, link_rel defaults to 'noopener noreferrer' and is force-added to every anchor, which surprises people diffing expected output in tests, and if you want to allow a rel attribute through you must also set link_rel=None or the two conflict. Finally, this cleans HTML only. Sanitizing user Markdown means rendering it to HTML first and cleaning the result, never the other way around.
Patterns
Sanitize a fragment with the built-in policyclean-with-defaults
import nh3
nh3.clean("<unknown>hi")
# 'hi'
nh3.clean("<b><img src='' onerror='alert(1)'>XSS?</b>")
# '<b><img src="">XSS?</b>'
nh3.clean('<a href="https://example.com">link</a>')
# '<a href="https://example.com" rel="noopener noreferrer">link</a>'Unknown tags are unwrapped and their text kept; event handler attributes are dropped entirely. Note the rel that appeared on the anchor: link_rel defaults to 'noopener noreferrer' and is added to every link, which is correct behaviour and a surprise the first time a test asserts on exact output. The defaults allow roughly sixty tags including img and table, so this is a rich-text policy, not a minimal one.
Allow only the tags you actually wantrestrict-tags
import nh3
ALLOWED = {"b", "i", "em", "strong", "a", "code", "p", "br"}
nh3.clean("<b><a href='https://example.com'>Hello</a></b>", tags=ALLOWED)
# '<b><a href="https://example.com" rel="noopener noreferrer">Hello</a></b>'
# start from the default set instead of listing everything
no_images = nh3.ALLOWED_TAGS - {"img", "table", "thead", "tbody", "tr", "td", "th"}
nh3.clean(user_html, tags=no_images)tags takes a set, not a list, and passing a list is the most common porting mistake from bleach. A tag that is not allowed is unwrapped, so the text inside survives: <script>alert(1)</script> becomes the literal text alert(1) on the page, which is inert but ugly. If you want the contents gone too, that is clean_content_tags, below.
Remove the element and everything inside itstrip-tag-contents
import nh3
nh3.clean("<script>alert('xss')</script>safe")
# "alert('xss')safe" <- text survives
nh3.clean("<script>alert('xss')</script>safe", clean_content_tags={"script"})
# 'safe'
nh3.clean(
"<style>body{display:none}</style><p>hi</p>",
clean_content_tags={"script", "style"},
)
# '<p>hi</p>'This is the difference between an unwrapped tag and a deleted subtree, and it matters for script and style, whose text content is noise once the tag is gone. clean_content_tags must be disjoint from the allowed tag set, including the default one when you omit tags, and overlapping them raises ValueError rather than picking a winner.
Control which attributes survive on which tagsper-tag-attributes
import nh3
nh3.clean(
'<a href="/" id="link" onclick="x()">click</a>',
tags={"a", "img"},
attributes={
"*": {"id", "title"}, # allowed on any tag
"a": {"href"},
"img": {"src", "alt", "width", "height"},
},
)
# '<a href="/" id="link" rel="noopener noreferrer">click</a>'
# strip every attribute
nh3.clean(user_html, attributes={})The '*' key is a union with the per-tag entry, not a fallback, so listing href under '*' allows it everywhere including on tags where it means nothing. Passing attributes={} is the strictest useful setting and is what escape(tags=...) does internally. Attributes are matched by name only here; if you need to constrain the value, use tag_attribute_values.
Compile the policy once for hot pathsreusable-cleaner
import nh3
# module level: build the allowlist a single time
COMMENT_CLEANER = nh3.Cleaner(
tags={"b", "i", "em", "strong", "a", "code", "pre", "p", "br", "blockquote"},
attributes={"a": {"href", "title"}},
url_schemes={"http", "https", "mailto"},
strip_comments=True,
)
def render_comment(raw: str) -> str:
return COMMENT_CLEANER.clean(raw)nh3.clean(html, tags=...) rebuilds the policy on every call, so a loop over a thousand comments constructs the allowlist a thousand times and throws away most of the speed advantage over bleach. Cleaner is the same options with the construction hoisted out. It has no mutable per-call state, so a module-level instance is fine to share across threads and requests.
Rewrite or drop attributes with a callbackrewrite-attributes
from copy import deepcopy
import nh3
attributes = deepcopy(nh3.ALLOWED_ATTRIBUTES) # deepcopy, not copy
attributes["a"].add("class")
def attribute_filter(tag: str, attr: str, value: str) -> str | None:
if tag == "a" and attr == "class":
return "mention" if "mention" in value.split(" ") else None
if tag == "img" and attr == "src" and not value.startswith("https://cdn."):
return None # drop the attribute
return value # keep unchanged
nh3.clean("<a class='mention unwanted'>@foo</a>",
attributes=attributes, attribute_filter=attribute_filter)
# '<a class="mention" rel="noopener noreferrer">@foo</a>'Return None to remove the attribute, or a string to replace its value. The callback only runs for attributes that already passed the allowlist, so it narrows policy and never widens it. deepcopy matters: ALLOWED_ATTRIBUTES is a dict of sets and attributes['a'].add(...) on the original mutates the default policy for every other caller in the process.
Block javascript: URLs and control relurl-schemes-and-rel
from copy import deepcopy
import nh3
nh3.ALLOWED_URL_SCHEMES
# {'http', 'https', 'mailto', 'tel', 'ftp', ...}
web_only = {"http", "https", "mailto"}
nh3.clean('<a href="javascript:alert(1)">x</a>', url_schemes=web_only)
# '<a rel="noopener noreferrer">x</a>' <- href removed, text kept
# keep an author-supplied rel instead of forcing one
attributes = deepcopy(nh3.ALLOWED_ATTRIBUTES)
attributes["a"].add("rel")
nh3.clean("<a href='https://tag.example' rel='tag'>#tag</a>",
link_rel=None, attributes=attributes)
# '<a href="https://tag.example" rel="tag">#tag</a>'A disallowed scheme drops the href and leaves the anchor, so the link becomes dead rather than disappearing. data: is not in the default set, which is what stops data:text/html payloads. If you allow rel through attributes you must also pass link_rel=None, otherwise the forced value and the author value collide; and dropping noopener on user links reintroduces the window.opener attack you were defending against.
Deny or rewrite relative URLsrelative-urls
import nh3
nh3.clean('<a href="/foo">x</a>', url_relative="deny")
# '<a rel="noopener noreferrer">x</a>'
nh3.clean('<a href="/foo">x</a>',
url_relative=("rewrite_with_base", "https://example.com"))
# '<a href="https://example.com/foo" rel="noopener noreferrer">x</a>'
nh3.clean('<img src="/a.png">',
url_relative=lambda url: f"https://cdn.example.com{url}")The default is pass_through, which means user content can link to your own routes with paths of its own choosing; that is fine for a wiki and wrong for an email digest where a relative URL resolves against the mail client. A callable that raises or returns a non-string strips the URL and reports the error through sys.unraisablehook, so a broken rewriter fails quietly rather than aborting the request.
Allow specific CSS classes and style propertiesclasses-and-styles
import nh3
# per-tag class allowlist (do not also allow the class attribute)
nh3.clean('<span class="highlight bold">text</span>',
allowed_classes={"span": {"highlight"}})
# '<span class="highlight">text</span>'
# allow style, then restrict which properties may appear
nh3.clean('<span style="color: red; position: fixed">text</span>',
attributes={"span": {"style"}},
filter_style_properties={"color", "text-align"})
# '<span style="color:red">text</span>'
# namespace ids so user content cannot collide with your page
nh3.clean('<b id="x">hi</b>', attributes={"b": {"id"}}, id_prefix="user-content-")Allowing the raw style attribute with no filter_style_properties lets user content set position:fixed and cover your page with a clickjacking overlay, so the two go together or not at all. allowed_classes replaces the class attribute check, so listing class in attributes as well makes the allowlist useless. id_prefix is the same defence GitHub applies to rendered README anchors.
When the input is text, escape itescape-instead-of-clean
import nh3
nh3.escape('Robert"); abuse();//')
# 'Robert"); abuse();//'
nh3.escape("<span>hello <mention>moto</mention>!</span>", tags={"mention"})
# 'hello <mention>moto</mention>!'
nh3.clean_text("same function, older name")escape() and clean_text() are the same function; escape is the preferred name because it escapes input rather than sanitizing markup. It is stricter than the standard library html.escape, which only handles the five characters, whereas this encodes everything with meaning to the parser. Use it when a field is supposed to be plain text: escaping is a smaller decision surface than an allowlist, so prefer it whenever HTML is not actually wanted.
Check whether a string contains markupdetect-html
import nh3
nh3.is_html("plain text") # False
nh3.is_html("<p>html!</p>") # True
# a plausible use: only pay for sanitizing when there is markup
def render(field: str) -> str:
return nh3.clean(field) if nh3.is_html(field) else nh3.escape(field)This parses the whole string, so it is not a cheap pre-check; on short fields calling clean directly is usually faster than testing first. It is also loose about what counts as HTML, returning True for things like <g> and for Rust turbofish syntax such as Vec::<u8>::new(), so do not use it to decide whether a user meant to write markup.
Port a bleach call, including the part that does not portmigrate-from-bleach
# before
import bleach
bleach.clean(text, tags=["b", "a"], attributes={"a": ["href"]}, strip=True)
bleach.linkify(text)
# after
import nh3
nh3.clean(text, tags={"b", "a"}, attributes={"a": {"href"}})
# linkify: no equivalent, do it before cleaning
import re
URL = re.compile(r"(?<!\"|')(https?://[^\s<]+)")
linked = URL.sub(r'<a href="\1">\1</a>', nh3.escape(text))
nh3.clean(linked, tags={"a"}, attributes={"a": {"href"}})Lists become sets, strip= has no counterpart because nh3 always unwraps rather than escaping disallowed tags, and styles= is now filter_style_properties. The linkify gap is the real work: escape the text first so the regex cannot match inside markup a user wrote, then insert your own anchors, then clean the result so a crafted URL cannot break out of the href you built.
Alternatives
| Package | Registry | Pick it when |
|---|---|---|
| bleach | PyPI | You need linkify or you are maintaining old code and cannot change call sites, accepting that the project is deprecated and no longer receiving fixes |
| lxml-html-clean | PyPI | You are already using lxml to parse the document and want cleaning from the same tree instead of a second parse |
| html-sanitizer | PyPI | You want opinionated normalization of editor output, such as merging tags and unwrapping empty elements, not just an allowlist filter |