HTML Escaping and XSS: The Five Characters
Published 2026-10-31
A user submits <script>alert(1)</script>, the page renders it verbatim, the script runs. That is XSS in one line, and the obvious fix — strip the angle brackets — is exactly where the bug hides.
Five characters, not two
| Character | Entity | Why |
|---|---|---|
& |
& |
Starts an entity; unescaped it can turn < into < |
< |
< |
Opens a tag |
> |
> |
Closes a tag (not strictly required, universally escaped) |
" |
" |
Breaks out of a double-quoted attribute |
' |
' |
Breaks out of a single-quoted attribute |
Escaping only < and > is the most common incomplete implementation. In attribute context, the quote is the real weapon:
<input value="USER_INPUT">
Submit " onfocus="alert(1) and you get:
<input value="" onfocus="alert(1)">
Not a single angle bracket involved.
The & problem is subtler: submit <script> and the browser displays <script> as text. Harmless — until the same value is written into a second parsing layer, where it becomes a real tag.
Context decides the rule
The same value in four positions needs four different treatments:
| Context | Example | Treatment |
|---|---|---|
| HTML text | <p>HERE</p> |
Escape the five characters |
| HTML attribute | <div title="HERE"> |
Escape five, and quote the attribute |
| JavaScript string | var x = 'HERE'; |
Not HTML escaping — use JSON.stringify, or put the value in a data-* attribute |
| URL parameter | <a href="/s?q=HERE"> |
URL-encode first, then HTML-escape for the attribute |
The dangerous combination is JavaScript inside an HTML attribute:
<a href="#" onclick="go('USER_INPUT')">
Here ' is neither harmless (it closes the JS string) nor something HTML escaping catches in a double-quoted attribute. Modern apps avoid inline handlers entirely — put the value in data-* and read it via dataset.
Escaping is not sanitising
| Encoding | Sanitising | |
|---|---|---|
| Purpose | Stop data being parsed as code in one context | Delete dangerous tags and attributes |
| Changes the data | No — only the output | Yes — deleted parts are gone |
| Use for | Plain text input (names, titles, comments) | Rich text (bold, links, images allowed) |
Escaping on input is a bug. If you store < in the database, that data is now only usable as HTML: CSV exports contain entities, API consumers receive mangled text, and length checks are wrong.
Store the original. Encode at the moment of output, per context.
Rich text needs an allowlist
If users can format text, escaping defeats the purpose — <b> becomes literal text. You need allowlist sanitising:
import DOMPurify from 'dompurify';
const clean = DOMPurify.sanitize(userHtml, {
ALLOWED_TAGS: ['b', 'i', 'a', 'p', 'br', 'img'],
ALLOWED_ATTR: ['href', 'src', 'alt'],
});
Three rules:
- Never sanitise HTML with a regex. HTML is not a regular language;
<scr<script>ipt>and its relatives defeat hand-written rules. - Validate
hrefseparately.javascript:anddata:text/htmlexecute. Allow onlyhttp:,https:,/,#. - Sanitise on the server at least once. Client-side filtering is a convenience, not a control — attackers call the API directly.
The reverse task — turning HTML into plain text, e.g. pasting page content into an email — is HTML Strip. That is data cleaning, not security; it removes tags but does not make output safe for a rich-text context.
Three real-world traps
1. Every template engine has an escape hatch. Handlebars {{{ }}}, Vue v-html, React dangerouslySetInnerHTML. Escaping is the default; the unescaped path is opt-in and that is where incidents happen. Before using it, ask: is this HTML mine or the user's?
2. Double escaping shows &lt;. If the page displays < instead of <, the value was encoded twice. Check the page source: &lt; in the source means double encoding. Fix the first encoding, not the display.
3. JSON is not automatically safe. Sending user input in a JSON response is fine — unless that JSON is inlined into a <script> tag during SSR, where a </script> in the data closes the tag early. That case needs JSON escaping: write < as \u003c.
The rule to memorise
Encode once, at output, for the context it lands in. The database always holds the original.
Three corollaries:
- Plain text input → HTML-escape the five characters on output.
- Rich text input → server-side allowlist, validate
hrefprotocols. - Always quote attributes; never build inline handlers from data.