Eternalsix

Escaping is about context: HTML entities, JSON strings, and where XSS comes from

There is no such thing as 'escaped text' — only text escaped for a specific place. Here's what each context needs, the bugs that come from mixing them, and why you escape on output.

Encoding converts data into a transport-safe form and back — Base64, percent- encoding. Escaping is different: it takes text that is already correct and makes it survive being placed inside something else, where certain characters would otherwise be read as structure rather than content.

The distinction that causes the most bugs is that escaping has no single correct answer. A string that is perfectly escaped for HTML text is wrong inside an HTML attribute, wrong inside a <script> block, and wrong inside a URL. There is no "escaped" state a string can be in. There is only escaped for a destination.

HTML: five characters, and the rules change by position

In HTML body text, the characters that carry structural meaning are <, >, and &. Replace them with entities and text stays text:

<b>hi</b>   →   &lt;b&gt;hi&lt;/b&gt;
Tom & Jerry →   Tom &amp; Jerry

& must be escaped first. Escape < to &lt; and then escape &, and you get &amp;lt; — the visible text becomes &lt; instead of <. This double-escaping is the most common cosmetic bug in the area, and it usually means a value passed through two layers that each thought they were responsible.

Inside an attribute the rules widen, because quotes now terminate structure:

<a title="&quot;quoted&quot;">
<a title='&#39;quoted&#39;'>

And an unquoted attribute is a different situation again — a space ends the value, so <a title=one two> has a stray two attribute. Always quote attributes; it removes a whole category of problem.

The one that bites: JSON inside a <script> tag

Serializing server data into a page looks harmless:

<script>
  const user = {"name": "..."};
</script>

The HTML parser finds the end of a <script> element by scanning for the literal text </script. It does this before any JavaScript runs, and it does not care that the sequence is inside a string literal. So a user whose name contains </script> closes your script block early and begins writing markup:

name = "</script><img src=x onerror=alert(1)>"

That is stored XSS, produced by correct JSON serialization placed in the wrong context. The fix is to escape the characters that matter to the HTML parser while staying valid JSON — < as <, > as >, & as &. Those escapes are legal inside JSON strings and parse back to the original characters, so nothing downstream changes.

Many frameworks do this for you when you use their "serialize to page" helper and do not when you interpolate a JSON string yourself. Knowing which one you're using is the whole game.

JSON strings: the two characters that broke JavaScript

JSON requires escaping for ", \, and control characters below U+0020 — that's \n, \t, \r, and friends. Everything else may appear literally.

Two code points used to make that untrue in practice: U+2028 (line separator) and U+2029 (paragraph separator). They are valid unescaped in JSON, but before ES2019 they were not valid unescaped in a JavaScript string literal. So JSON containing a U+2028 — easy to get from copy-pasted rich text — parsed fine with JSON.parse and threw a syntax error the moment it was embedded directly into JavaScript source, as JSONP or inline script did.

Modern engines accept them, but old bundlers, old runtimes, and anything generating JS source at build time can still trip. If you embed JSON into JavaScript rather than parsing it at runtime, escape 
 and 
 explicitly.

Escape on output, never on input

The tempting design is to clean data as it arrives: strip tags, HTML-escape, and store the safe version. It fails for a structural reason — at input time you do not know where the value will be rendered. The same name will appear in an HTML page, a JSON API response, a plain-text email, a CSV export, and a log line. Escaped-for-HTML is wrong in four of those five.

Storing escaped data also makes the database lie. Tom &amp; Jerry is not the user's name; it's a rendering of it. Every later consumer must guess whether a given column is raw or escaped, and eventually one guesses wrong in each direction — either raw output (a vulnerability) or double-escaped output (a visible bug).

Store exactly what the user typed. Escape at the boundary, for the destination, every time. Validation at input is still worth doing — reject a 10,000-character name, reject an email with no @ — but validation and escaping are different jobs.

Where escaping stops being enough

Escaping makes data safe as content. It does not make it safe as code or addresses.

An attribute like href takes a URL, and javascript:alert(1) is a perfectly well-formed URL that contains no HTML-special characters at all. Entity-escaping does nothing to it. URLs in attributes need scheme allow-listing — permit http, https, mailto, and relative paths, reject everything else — on top of escaping.

Similarly, escaping never sanitizes HTML you intend to keep as HTML. If the feature is "users may post formatted text", you need a real sanitizer with an allow-list of elements and attributes. Escaping and sanitizing solve different problems, and reaching for the wrong one is a common cause of both broken markup and live vulnerabilities.

Seeing what a string actually becomes

Most escaping bugs are invisible in a diff — &amp; and &amp;amp; look nearly identical at a glance, and a stray 
 is literally invisible. Converting a string in both directions makes the difference obvious. An HTML Entity Encoder / Decoder shows exactly which characters became entities, and a JSON String Escape / Unescape shows what a value looks like once it's a JSON string literal. Both run in your browser, which matters when the string you're debugging came out of production.

The takeaway

There is no escaped string, only a string escaped for a destination. Escape at output, for the context you're writing into — HTML text, HTML attribute, JSON string, URL — and store raw data unchanged. Escape & first or you produce double-escaped text. When embedding JSON in a <script> tag, escape <, >, and & as \u00XX, because the HTML parser finds </script before JavaScript ever runs. And remember the limits: escaping protects content, not URLs and not markup you intend to keep — those need allow-lists.

For the neighbouring topic of turning data into transport-safe form rather than protecting it in place, see Base64, URL encoding, and percent-encoding, and for why hashing is a third thing again, hashing vs. encryption vs. encoding.