Safe DOM Insertion and Sanitisation

Image by Jr Korpa.

In Brief

Use textContent, innerText where older Internet Explorer support requires it, or a text node when a value should remain text. Use innerHTML only when the string is deliberately HTML and its contents are known to be safe. If users are allowed to supply markup, pass it through a proper restrictive sanitiser rather than trying to clean arbitrary HTML with a regular expression.

A string is not automatically safe to add to a page just because it came from our own JavaScript. It might contain a name entered into a form, a value returned by a service, or a fragment copied from another document. The important question is whether that string is meant to be text or markup.

That distinction decides which DOM operation we should use. If we get it wrong, the browser can turn ordinary data into elements and attributes, with results ranging from broken presentation to crosssite scripting.


Text and Markup are Different Operations

Consider a simple welcome message. The visitor's name is stored in a string and inserted into an element:

var message = document.getElementById('message');var visitorName = '<strong>Ellie</strong>';message.innerHTML = 'Hello ' + visitorName;

The browser does not display the angle brackets. It parses the value, creates a strong element, and displays the name in bold. That is exactly what innerHTML is for, but it is not what we wanted from a person's name.

The HTML5 working draft available in early 2011 describes setting innerHTML as parsing a string into DOM nodes. Once we choose that property, we are asking the browser to treat the value as HTML.

For text, the standard alternative is textContent:

var message = document.getElementById('message');var visitorName = '<strong>Ellie</strong>';message.textContent = 'Hello ' + visitorName;

This time, the visitor sees Hello <strong>Ellie</strong>. The brackets and tag name are text because no HTML parser is involved.


textContent and Internet Explorer

textContent is not a new invention. It is defined by the W3C's DOM Level 3 Core specification, and has been available in Firefox, Safari, Opera, and other standardsbased browsers for some time.

Internet Explorer is the awkward part. Internet Explorer 9 now supports textContent, but Internet Explorer 8 and earlier use Microsoft's innerText property instead. A site which still supports those browsers can make the choice explicitly:

function setText(element, value) {  if (typeof element.textContent !== 'undefined') {    element.textContent = value;  } else {    element.innerText = value;  }}setText(document.getElementById('message'), 'Hello <strong>Ellie</strong>');

There are differences between textContent and innerText when values are read. In particular, innerText reflects rendered text more closely, so hidden content and line breaks can behave differently. For the simple job of replacing the contents of an ordinary element with text, the fallback is useful, but it is worth remembering that the properties are not otherwise identical.

If all we need is a dependable way to insert text, a text node avoids that property difference entirely:

function replaceWithText(element, value) {  while (element.firstChild) {    element.removeChild(element.firstChild);  }  element.appendChild(document.createTextNode(value));}

document.createTextNode() has been supported for much longer and makes the intention very clear. The value is text, not a fragment of HTML.


Build Known Markup from DOM Nodes

Sometimes the result does need a little markup, but only part of the content is trusted. We might want a fixed word in bold followed by a name supplied by a visitor. Concatenating both into an HTML string gives the untrusted value the same authority as our own markup.

Instead, we can create the element we know we need and add the changing value as text:

var message = document.getElementById('message');var label = document.createElement('strong');var visitorName = '<img src="missing" onerror="alert(1)">';label.appendChild(document.createTextNode('Signed in as'));message.appendChild(label);message.appendChild(document.createTextNode(' ' + visitorName));

The strong element is markup that we control. The name is still only a text node, so its apparent img element and event attribute are displayed rather than interpreted.

This is also why removing script tags is not enough protection. An eventhandler attribute, a dangerous URL, or malformed markup that the browser repairs can all provide routes to script execution. A filter which searches for one tag name is solving only one example of a much wider problem.


Escaping and Sanitising Solve Different Problems

If a value is meant to be text, the textonly DOM methods are usually better than manually replacing angle brackets and ampersands. They keep data out of the HTML parser in the first place, and there is no list of characters for us to forget.

If a value is genuinely meant to contain HTML, escaping every tag changes the feature: the visitor sees the markup rather than its formatted result. In that situation, the value needs sanitising. A suitable sanitiser permits only a small set of elements and attributes, rejects unsafe URL schemes, and copes with the browser's HTML parsing rules. OWASP's 2010 guidance recommends a dedicated HTML sanitisation library for larger blocks of usersupplied markup, rather than treating output encoding as the same operation.

A regular expression can be useful for a tightly defined text format, but arbitrary HTML is not a tightly defined text format. Tags can be nested, attributes can be quoted in several ways, entities can disguise characters, and browsers recover from broken markup. A growing collection of substitutions quickly becomes a sanitiser of our own, without the testing and parser knowledge that such a security boundary needs.

The safest decision is normally made before any cleaning code is written. If the feature does not require markup, insert text. If it needs a few elements we control, build those elements and add changing values as text nodes. Only accept an HTML fragment when the feature really needs it, and then use a proven, restrictive sanitiser.

Postscript

Aug 2026: This article forms part of an archive restored from a previous version of my website. Its original publication date is accurate. During the restoration, I reviewed and updated it where appropriate for formatting, imagery, broken links, code correctness, and current internal references, whilst preserving the original technical context and intent. The distinction between text and markup remains the important one. Current OWASP DOMbased XSS guidance treats textContent as a safe sink for text and recommends a maintained sanitiser when HTML is unavoidable. For a modern contentsystem example, see Rendering CMS Rich Text Safely in Gatsby and React.

Need a senior engineer involved?

I can work directly in the codebase, review the architecture, or support the team through delivery when the work needs more than extra hands.