ibit
Notes

The browser finally owns HTML sanitization

WRWhite Rabbit·June 29, 2026·3 min

For years the standard answer to "I need to render user HTML" was: pull in DOMPurify, run the string through it, then assign to innerHTML. It worked. I've shipped it. But there was always something structurally off about the arrangement, and the new HTML Sanitizer API is what made the discomfort legible to me.

DOMPurify is a library re-implementing the browser's own parser, in userland, and racing to stay in sync with every new platform feature. The browser parses HTML one way; the sanitizer has to predict that parsing to decide what's dangerous. Every gap between the prediction and the reality is a potential mutation-XSS bypass — and that whole bug class exists only because the thing deciding what's safe is not the thing that executes the result. Ahmad Alfy's write-up of the Sanitizer API is what crystallized this for me: the fix isn't a better predictor, it's deleting the prediction.

The API's whole shape is one distinction: safe methods versus unsafe ones. Element.setHTML(), ShadowRoot.setHTML(), and Document.parseHTML() always strip <script>, the framing elements, and every on* event-handler attribute — even if your config tries to allow them. Your config is only a further restriction on a safe baseline. The ...Unsafe() variants (setHTMLUnsafe() and friends) treat your config as the complete rule: allow onclick and it stays. The naming does the teaching. If you're reaching for the "Unsafe" method, you're meant to feel it.

One footgun worth naming: the config is an allow-list or a block-list, never both. Pass elements (allow) alongside removeElements (block) and it throws a TypeError. That's a good constraint — "allow these, except also remove these" is exactly the kind of muddled policy that hides holes.

The honest status, as of 2026: don't assume it's there. Firefox shipped it first, Chromium followed, and Safari hasn't yet — it is not Baseline. So feature-detect and fall back:

if ("setHTML" in Element.prototype) {
  el.setHTML(untrusted);
} else {
  el.replaceChildren(); // or DOMPurify, or just textContent
  el.append(DOMPurify ? parse(untrusted) : document.createTextNode(untrusted));
}

And the caveat the API can't fix, the one I'd put in bold over every adoption: this is a client-side tool. It never replaces backend sanitization. A client method can be called directly — skipping your UI entirely — so the server is where the real boundary lives. For a site like this one, static-exported and rendering Markdown into the DOM, the same rule holds: sanitize when content is built, and treat setHTML as the second layer that catches what the first missed.

The browser owning sanitization is genuinely better. It's just better defense-in-depth, not a new outer wall.