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.
- javascript
- security
- dom
- interview
A comment field stores <img src=x onerror=alert(1)>. The candidate writes html.replaceAll('<', '<') and then replaceAll('&', '&'). The less-than is now &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
textContentorcreateTextNode. Escaping so you can assigninnerHTMLis a smell.- If you escape and then assign
textContent, the user sees<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 |
|---|---|---|
& |
& |
Starts a character reference. Must go first in a chained replace. |
< |
< |
Starts a tag. |
> |
> |
Ends a tag. Often optional in text; required in this interview set. |
" |
" |
Delimits double-quoted attributes. |
' |
' |
Delimits single-quoted attributes. Numeric so you do not argue about '. |
' is in the HTML named character references table and in XML. Older HTML4 parsers did not have it. ' is the boring choice that survives both.
Single-pass replace. Each match is substituted once; the inserted & is not fed back into the regex.
const HTML_ESCAPES = {
'&': '&',
'<': '<',
'>': '>',
'"': '"',
"'": ''',
};
function escapeHtml(value) {
return String(value).replace(/[&<>"']/g, (ch) => HTML_ESCAPES[ch]);
}
escapeHtml(`<a href="x">Tom & Jerry</a>`);
// '<a href="x">Tom & Jerry</a>'
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('&', '&')
.replaceAll('<', '<')
.replaceAll('>', '>')
.replaceAll('"', '"')
.replaceAll("'", ''');
}
Swap the first two lines and '<' becomes &lt;. The browser then shows < 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 <img... and creates a text node. It is still the wrong default:
- The next edit interpolates the same helper into an attribute, a URL, or a
<script>body and the table no longer matches the context. - Double encoding: escape, then
textContent, and the page shows entities. - Reviewers cannot tell a safe
innerHTMLfrom 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”:
- Name the sink: text node, quoted attribute, URL, HTML fragment, script data.
- If it is a text node, use
textContent. Stop. - If it is a quoted attribute you must build as a string, escape the five characters (or at least
&, quotes, and<). - If it is a URL, parse with the
URLconstructor and allowlisthttp:/https:(andmailto:if you mean to). Do not regexjavascript. - 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
&last.<becomes&lt;. Visible entities, confused testers.- Only
<and>. Quotes left intact break out of attributes:" onclick="alert(1). - Escape then
textContent. Double encoding. innerHTMLas the default sink. One missed call site is XSS. See XSS.- Calling it a sanitizer. Reviewers will stop looking for
javascript:and SVG. - Not coercing the input.
escapeHtmlshouldString(value)or throw.nulland 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
'. This is encoding, not a sanitizer: it will not savejavascript:in an href, SVG, or an event handler. For UI text I would not call this at all; I would settextContent. 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
- Named character references: HTML Living Standard
- Serializing HTML fragments (escape a string): WHATWG
Node.textContent: MDNElement.innerHTML: MDN
Related on this site
- JSON.stringify implementation
- Form serialize to JSON
- XSS for frontend engineers
- XSS prevention checklist
- HTML entities and encoding
- Creating and updating DOM nodes
- Safe URL handling
- Content Security Policy
- Tagged template sanitization
Related guides
- CSS Selector GeneratorBuild a unique CSS path from a node to the root: id when unique, then nth-child among siblings, and stop before you invent a full query engine.
- Serialize a Form to JSONRead FormData, group duplicate names into arrays, skip disabled controls, and handle file inputs without pretending they are strings.
- Walk the DOM Like getElementsByClassNameDFS or BFS a subtree for class tokens. Live vs static NodeList, classList vs className split, and why querySelectorAll is the modern answer.
- Analytics SDK Event QueueQueue analytics events, send them in series, retry the ones that fail, and flush on page hide without dropping the last click.
- Arrays That Emit Change EventsWrap push, pop, and splice so subscribers hear adds and removes. Proxy vs subclass, and why mutating via index assignment still slips through.