ESC

Type to search the knowledge base.

HTML Escape and Encode

Replace the five XML characters in user text. This is encoding, not a sanitizer, and innerHTML of escaped text is still the wrong default.

beginner7 min read
  • javascript
  • security
  • dom
  • interview

A comment field stores <img src=x onerror=alert(1)>. The candidate writes html.replaceAll('<', '&lt;') and then replaceAll('&', '&amp;'). The less-than is now &amp;lt;, the page shows the entity instead of the character, and a second payload in an href still runs because HTML escape never touched javascript:. Encoding the five XML characters is a real, small function. It is not a sanitizer, and innerHTML of the escaped string is still the wrong default.

The interview is the replacement table, the order (& first), and the honesty about what this does not stop. Prefer textContent. Link the rest of XSS to the XSS page.

Key takeaways

  • Five characters: &, <, >, ", '. That is the XML set. It is not “every dangerous character.”
  • Replace & first, or use a single-pass map so replacements are not scanned again.
  • Encoding is not sanitizing. javascript: URLs, SVG, onerror, and unquoted attributes still exist.
  • Prefer textContent or createTextNode. Escaping so you can assign innerHTML is a smell.
  • If you escape and then assign textContent, the user sees &lt; literally. Pick one sink.

Replace the five XML characters, do not invent a sanitizer

HTML and XML treat these characters as syntax. User text that should stay text has to mention them without becoming markup.

Character Replacement Why
& &amp; Starts a character reference. Must go first in a chained replace.
< &lt; Starts a tag.
> &gt; Ends a tag. Often optional in text; required in this interview set.
" &quot; Delimits double-quoted attributes.
' &#39; Delimits single-quoted attributes. Numeric so you do not argue about &apos;.

&apos; is in the HTML named character references table and in XML. Older HTML4 parsers did not have it. &#39; is the boring choice that survives both.

Single-pass replace. Each match is substituted once; the inserted &amp; is not fed back into the regex.

const HTML_ESCAPES = {
  '&': '&amp;',
  '<': '&lt;',
  '>': '&gt;',
  '"': '&quot;',
  "'": '&#39;',
};

function escapeHtml(value) {
  return String(value).replace(/[&<>"']/g, (ch) => HTML_ESCAPES[ch]);
}

escapeHtml(`<a href="x">Tom & Jerry</a>`);
// '&lt;a href=&quot;x&quot;&gt;Tom &amp; Jerry&lt;/a&gt;'

escapeHtml('');          // ''
escapeHtml(42);          // '42'
escapeHtml(null);        // 'null'  (String(null)); say if you prefer ''

Chained replaceAll is the same contract if & is first:

function escapeHtmlChained(value) {
  return String(value)
    .replaceAll('&', '&amp;')
    .replaceAll('<', '&lt;')
    .replaceAll('>', '&gt;')
    .replaceAll('"', '&quot;')
    .replaceAll("'", '&#39;');
}

Swap the first two lines and '<' becomes &amp;lt;. The browser then shows &lt; instead of <. That is the order question. A lookup table plus one regex does not have an order bug, which is why it is the version to write under time pressure.

Do not split('').map. You will break supplementary-plane characters that JS exposes as surrogate pairs, and you will still need the same table.

Prefer textContent, not escaped innerHTML

If the sink is a text node, you do not encode by hand.

el.textContent = userName;
el.append(document.createTextNode(userName));

The DOM stores the string. The HTML serializer encodes on the way out. You never parse the user’s bytes as markup. That is the default in creating DOM nodes.

el.innerHTML = escapeHtml(userName) happens to be safe for a text-only HTML context: the parser sees &lt;img... and creates a text node. It is still the wrong default:

  1. The next edit interpolates the same helper into an attribute, a URL, or a <script> body and the table no longer matches the context.
  2. Double encoding: escape, then textContent, and the page shows entities.
  3. Reviewers cannot tell a safe innerHTML from an unsafe one without reading the right-hand side.

React text children already encode. dangerouslySetInnerHTML does not. Vue v-text vs v-html is the same split. The function on this page is for the cases where you are building an HTML string (email template, SSR snippet, srcdoc). It is not permission to go back to innerHTML in the UI.

Encoding is not a sanitizer

A sanitizer parses HTML and drops or rewrites dangerous constructs. This function never parses. It turns syntax characters into references so the parser will not treat them as syntax. That is a different job.

What escapeHtml does not stop:

Sink Why encoding the five chars is not enough
a.href = userUrl javascript:alert(1) has none of & < > " ' in a typical payload. See safe URL handling.
el.setAttribute('onclick', ...) Event handler names are not in the table.
SVG / MathML Different DOM, different script surfaces.
Unquoted attribute value=foo onclick=... does not need a quote to break out if you never quoted.
script or style element bodies Closing tags and CSS escapes are their own languages.
Markdown / rich text The renderer will unescape and parse tags you “escaped” one layer up.

If they ask you to “sanitize HTML,” say no to a regex, name a real sanitizer (or the HTML Sanitizer API where it exists), and point at CSP as defense in depth. Do not grow escapeHtml into a tag allowlist on the whiteboard. You will miss a vector and ship a false sense of safety.

Tagged templates that auto-escape interpolations (the idea) are this function applied at each interpolation, still not a sanitizer.

Context matters: text versus attribute versus URL

Procedure when someone says “escape this for the DOM”:

  1. Name the sink: text node, quoted attribute, URL, HTML fragment, script data.
  2. If it is a text node, use textContent. Stop.
  3. If it is a quoted attribute you must build as a string, escape the five characters (or at least &, quotes, and <).
  4. If it is a URL, parse with the URL constructor and allowlist http: / https: (and mailto: if you mean to). Do not regex javascript.
  5. If it is a fragment of markup the user is allowed to style, that is sanitization. Different interview.

The WHATWG serializer’s “escape a string” is even narrower than the five-char table: &, no-break space, then " in attribute mode, or < and > in text mode. Interviews still want the five XML characters. Say you are implementing the interview table, not cloning the serializer.

HTML entities are how these references work in source. This page is the encoder you write in JS.

Footguns of HTML escaping

  1. & last. &lt; becomes &amp;lt;. Visible entities, confused testers.
  2. Only < and >. Quotes left intact break out of attributes: " onclick="alert(1).
  3. Escape then textContent. Double encoding.
  4. innerHTML as the default sink. One missed call site is XSS. See XSS.
  5. Calling it a sanitizer. Reviewers will stop looking for javascript: and SVG.
  6. Not coercing the input. escapeHtml should String(value) or throw. null and numbers show up.

replace without the global flag fixes the first & only. Use replaceAll or /[&<>"']/g.

Interview answer (out loud)

“I replace the five XML characters, ampersand first so I do not double-encode the other entities, and I use a single-pass table so order cannot drift. Apostrophe becomes &#39;. This is encoding, not a sanitizer: it will not save javascript: in an href, SVG, or an event handler. For UI text I would not call this at all; I would set textContent. If they need markup from the user, that is a sanitizer plus CSP, and I will not fake one with a regex. innerHTML of escaped text is a last resort for string templates, not the default DOM write.”

Further reading

Related guides