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> → <b>hi</b>
Tom & Jerry → Tom & Jerry
& must be escaped first. Escape < to < and then escape &, and you get
&lt; — the visible text becomes < 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=""quoted"">
<a title=''quoted''>
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 & 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 — & and &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.