I was debugging a client’s dashboard when the console started spitting out a warning: a stray <script> tag had made it into a table cell rendered with innerHTML. The data came from a CSV import, so I assumed it was safe, after all, the file was generated by the client’s own system. Turns out a single malformed row let an attacker inject a payload that executed as soon as the page loaded. The whole afternoon vanished chasing a bug that should have been prevented by a single line of sanitization.

The hidden danger of innerHTML

innerHTML is the go-to shortcut for dumping HTML strings into the DOM. It works fine for static fragments you control, but when the string originates from any external source, user input, API responses, file uploads, it becomes a vector for cross-site scripting (XSS). The browser parses the string as HTML, creates elements, and executes any <script> tags or event-handler attributes it finds.

A common misconception is that blocking <script> tags in the input is enough. Modern browsers will still execute code embedded in onerror, onclick, srcdoc, or even SVG onload. That’s why simple regex filters that strip <script> often miss the real threats.

Why regex-based filters fail

  • Context blind, Regex can’t understand HTML nesting. <script> inside a comment or CDATA block may be missed.
  • Attribute injection, <img src=x onerror=alert(1)> passes a <script>-only filter but still runs.
  • Encoding tricks, Entities like &#x3C;script&#x3E; decode to a script tag after the browser parses the string.

Because of these edge cases, a regex that "looks for <script>" gives a false sense of security while still letting many payloads through.

A safer approach: DOM-based sanitization

Instead of trying to guess what’s dangerous, let the browser help you. The idea is to parse the string into a temporary DOM, walk the tree, and remove any node or attribute that isn’t on a whitelist.

// sanitize.js
function sanitizeHtml(dirty) {
  const parser = new DOMParser();
  const doc = parser.parseFromString(dirty, 'text/html');
  const whitelist = {
    tags: ['b', 'i', 'u', 'strong', 'em', 'a', 'p', 'br', 'ul', 'ol', 'li', 'span'],
    attrs: { a: ['href', 'title'], span: ['style'] }
  };

  function walk(node) {
    // Remove disallowed elements
    if (node.nodeType === Node.ELEMENT_NODE) {
      const tag = node.tagName.toLowerCase();
      if (!whitelist.tags.includes(tag)) {
        node.remove();
        return;
      }
      // Clean attributes
      const allowedAttrs = whitelist.attrs[tag] || [];
      [...node.attributes].forEach(attr => {
        if (!allowedAttrs.includes(attr.name.toLowerCase())) {
          node.removeAttribute(attr.name);
        }
      });
    }
    // Recurse
    node.childNodes.forEach(walk);
  }

  walk(doc.body);
  return doc.body.innerHTML;
}
  • DOMParser parses the string safely, no script execution occurs.
  • The whitelist.tags array defines which elements are allowed.
  • whitelist.attrs maps each tag to the attributes you permit.
  • The walk function removes everything else before you ever call innerHTML on the live page.

Why this works

  • The browser handles entity decoding, so encoded <script> becomes a real element that you can spot and discard.
  • Event-handler attributes never get a chance to run because they are stripped before the node is attached to the live DOM.
  • You keep the output as HTML, not as raw text, preserving formatting where you want it.

When to use a library instead of DIY

Writing a sanitizer is error-prone. The community has vetted solutions like DOMPurify, which follows the same principle but includes a huge list of edge cases, CSP integration, and optional hooks for custom rules. If you’re building a large app or handling rich user-generated content, pull in DOMPurify:

import DOMPurify from 'dompurify';
const clean = DOMPurify.sanitize(dirtyHtml);
container.innerHTML = clean;
  • DOMPurify automatically strips dangerous URLs (javascript:) and sanitizes SVG.
  • It respects a configurable ALLOWED_TAGS and ALLOWED_ATTR list, so you can keep the same whitelist philosophy.

Practical warning: never trust innerHTML on the server side

If you pre-render HTML on the server and send it to the client, the same XSS risk applies. The server must sanitize before sending the string. A common mistake is to think “the server is safe because it runs Node.js”. The Node process still parses the string when the browser receives it, so the same sanitization step is required.

Performance considerations

Parsing with DOMParser adds a tiny overhead, but it’s negligible compared to the cost of an XSS exploit. For high-frequency updates (e.g., live chat), you can create a reusable documentFragment and reuse the parser, but avoid re-creating the whitelist on every call.

Testing your sanitizer

Write a few unit tests with known payloads:

const payloads = [
  '<img src=x onerror=alert(1)>',
  '<svg/onload=alert(2)>',
  '&#x3C;script&#x3E;alert(3)&#x3C;/script&#x3E;'
];
payloads.forEach(p => console.assert(!sanitizeHtml(p).includes('alert')));
  • Each assert ensures the dangerous code disappears.
  • Add edge cases like malformed tags and CSS url(javascript:…).

FAQ

Add a hook that checks the href attribute against a whitelist of protocols (http, https, mailto). DOMPurify provides a FORBID_ATTR option you can use.

Will sanitizing break legitimate HTML like embedded YouTube iframes?

Yes, iframes are not in the default whitelist. Extend the whitelist to include iframe and explicitly allow only src attributes that match https://www.youtube.com/embed/.

Is it okay to use textContent instead of innerHTML?

If you don’t need any HTML formatting, textContent is the safest choice, it treats the string as plain text, eliminating XSS completely.

Wrapping up

Whenever you reach for innerHTML, pause and ask where the string comes from. If it’s anything but a hard-coded constant, run it through a DOM-based sanitizer or a battle-tested library like DOMPurify before you touch the live DOM. That extra step costs a few milliseconds but saves hours of debugging and protects your users from nasty script injections.