intact. The OWASP context rule, demonstrated in Node.","datePublished":"2026-09-03T00:00:00.000Z","dateModified":"2026-09-03T00:00:00.000Z","author":{"@type":"Organization","name":"Lunchbox Hands"},"publisher":{"@type":"Organization","name":"Lunchbox Hands"},"mainEntityOfPage":"https://lunchboxhands.com/blog/html-entities-vs-url-encoding-vs-escaping"}
🍱 Lunchbox Hands

security

HTML Entities vs URL Encoding vs String Escaping: Which One Stops XSS

There is no universal "sanitize" function. Each encoder neutralizes one parser and is inert or actively harmful in the next one over: HTML entities inside a script block corrupt data and a lone backslash still breaks out, an HTML-encoded onclick value is decoded before JavaScript ever sees it, and JSON.stringify leaves </script> intact. The OWASP context rule, demonstrated in Node.

A user-supplied value has to go into a page. You know that means “escape it,” so you call the escape function your framework hands you — or paste it into an online encoder — and the page renders fine.

Whether that was safe depends entirely on where in the page the value landed, not on which function you called. An HTML entity encoder makes a string inert to the HTML parser and does nothing to the JavaScript, URL, or CSS parsers, each of which has its own break-out characters. Worse, the browser runs those parsers in sequence, so an encoding that is correct for one layer can be undone by the layer before it. There is no universal sanitizer; there are five contexts, and the whole discipline is matching the encoder to the context.

The context rule

The OWASP Cross Site Scripting Prevention Cheat Sheet organizes XSS defense around exactly this idea: you output-encode for the context the data is going into. Its five contexts, and what each one requires:

Where the data landsExampleEncoderBreak-out characters
HTML body<div>HERE</div>HTML entity encoding< > &
HTML attribute (quoted)<div title="HERE">HTML attribute encoding" ' & — plus whitespace if unquoted
JavaScript stringvar x = "HERE";JavaScript \xHH encoding, only inside a quoted string" ' \ newline, </script
URL parameter?q=HEREPercent-encoding (encodeURIComponent) — only the value, never the whole URL& = # ?
CSS property valuestyle="color: HERE"CSS \HH encoding, only in a property value; } ( ) expression/url

Two lines from the cheat sheet carry most of the weight. On JavaScript: “the only ‘safe’ location for placing variables in JavaScript is inside a ‘quoted data value’. All other contexts are unsafe.” And on attributes: “It’s critical to use quotation marks like " or ' to surround your variables,” because quoting “significantly reduces the characterset that you need to encode.”

Percent-encoding has its own post — URL encoding explained covers %20 vs +, the two encodeURI functions, and the %2520 bug — so this one focuses on the HTML and JavaScript rows, where wrong-encoder mistakes actually cause XSS.

HTML body: five characters, three spellings each

For text between tags, the characters that matter are the ones the HTML tokenizer treats as markup: < opens a tag, & opens a character reference, and >, ", ' are encoded too so the same output is safe when it ends up in an attribute. OWASP’s table:

CharacterEntity
&&amp;
<&lt;
>&gt;
"&quot;
'&#x27;

The single quote gets a numeric reference, deliberately. &apos; is in the WHATWG named character references table (U+0027), so every modern browser decodes it — but it is absent from the HTML 4.01 entity list, which defines quot, amp, lt, gt and stops there; &apos; came from XML. Anything still parsing as HTML 4 — legacy email clients, typically — renders it as the literal six characters. Numeric references sidestep the question.

Every character has three spellings, and the browser treats them identically:

named    &amp;      only for names in the table
decimal  &#38;      &# + code point in base 10
hex      &#x26;     &#x + code point in base 16 — X is accepted too

Decimal &#39; and hex &#x27; are the same apostrophe; OWASP writes hex, our tools write decimal, neither is more secure. The tokenizer also accepts some legacy names without a semicolon — &amp, &lt, &copy — which the table flags as “intentional, for legacy compatibility.” Emit the semicolon anyway.

Double-encoding: &amp;amp;

The classic HTML-encoding bug is not under-encoding, it is encoding twice. Run a minimal escaper over its own output:

const h = s => s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;')
                .replace(/"/g,'&quot;').replace(/'/g,'&#39;');
h('a & b')      // 'a &amp; b'
h(h('a & b'))   // 'a &amp;amp; b'   ← renders as "a &amp; b"

The & in &amp; is itself an ampersand, so the second pass encodes it again and the user sees &amp; on the page. It happens whenever a value is encoded on the way into the database and again on the way out, or when a template engine auto-escapes something you already escaped by hand. The rule: store raw, encode exactly once, at the output boundary, for the context you are outputting into.

Attributes: the quotes are the encoding

Inside a double-quoted attribute value, the tokenizer’s attribute value (double-quoted) state recognizes exactly two special characters: " ends the value and & starts a character reference. Everything else — <, >, spaces — is appended verbatim. HTML-encode, quote, done.

Drop the quotes and the unquoted state applies: a tab, line feed, form feed, or space ends the value and switches to “before attribute name.” A space is now a break-out character, and no entity encoder touches spaces:

const v = 'x onmouseover=alert(1)';
`<div title=${h(v)}>`     // <div title=x onmouseover=alert(1)>   ← two attributes
`<div title="${h(v)}">`   // <div title="x onmouseover=alert(1)"> ← one attribute

Same payload, same encoder. The unquoted version is an XSS; the quoted version is a tooltip. Quoting is not stylistic — it is what makes the five-character encoder complete.

JavaScript: where HTML encoding stops working

Three mechanisms, each verified against the spec or executed in Node 22.

1. Inside a <script> block, entities are never decoded. Script content is script data, not HTML text; the tokenizer does not process character references there. So <script>var x = "<?= htmlEncode($v) ?>"</script> leaves &#39; and &lt; as literal text in the string — a data-corruption bug. The security failure is that HTML encoding never touches the characters JavaScript cares about: backslash and newline. Two injection points, neither payload containing &, <, >, ", or ':

const vm = require('node:vm');
const a = '\\';            // a single backslash
const b = ';alert(1)//';
const script = `var a = "${h(a)}"; var b = "${h(b)}";`;
// var a = "\"; var b = ";alert(1)//";
vm.runInNewContext(script, { alert: x => console.log('ALERT FIRED with', x) });
// ALERT FIRED with 1

The backslash in a escapes the closing quote, the string swallows ; var b = , the next " closes it, and ;alert(1)// runs as code. JSON.stringify escapes the backslash ("\\") and the same two payloads become inert. A raw newline is the other half: the HTML encoder passes it through and the script dies with SyntaxError: Invalid or unexpected token.

2. In an event-handler attribute, the browser decodes your encoding before running the code. <button onclick="go('<?= htmlEncode($v) ?>')"> looks doubly protected. It has zero protection: the attribute value is tokenized as an attribute first, which — per the double-quoted state above — turns &#39; back into ', and only then is the string handed to the JavaScript engine:

source:      <button onclick="go('&#39;);alert(1)//')">
JS receives: go('');alert(1)//')

The encoder did its job and the HTML parser undid it. This is why OWASP lists attributes that accept JavaScript, like onclick, as not safe for untrusted values regardless of encoding. Move the value to a quoted, HTML-encoded data-* attribute and read it from dataset in a real script.

3. JSON.stringify does not stop </script>. Serializing with JSON.stringify fixes the backslash and is almost right:

JSON.stringify('</script><script>alert(1)</script>')
// "</script><script>alert(1)</script>"   ← < and / untouched

Script data ends at an appropriate end tag — </ plus the name of the open element — and the tokenizer neither knows nor cares that it is inside a JavaScript string literal. The spec’s restrictions for contents of script elements give the fix: escape </script as \x3C/script (and <!-- as \x3C!--, <script as \x3Cscript, which must be balanced for legacy reasons). The common one-liner is JSON.stringify(v).replace(/<\//g, '<\\/'), since \/ is a valid escape in both JavaScript and JSON — executed, JSON.parse('"<\\/script>"') returns </script> unchanged.

What each encoder does to the same payload

Both payloads through a minimal HTML escaper, encodeURIComponent, and JSON.stringify, in Node 22:

PayloadHTML escaperencodeURIComponentJSON.stringify
<img src=x onerror=alert(1)>&lt;img src=x onerror=alert(1)&gt;%3Cimg%20src%3Dx%20onerror%3Dalert(1)%3E"<img src=x onerror=alert(1)>"
"';alert(1)//&quot;&#39;;alert(1)//%22'%3Balert(1)%2F%2F"\"';alert(1)//"

The HTML escaper neutralizes the tag but leaves the quotes’ JavaScript meaning intact. encodeURIComponent leaves ', (, ), *, !, ~ alone — so it is not a JavaScript encoder either — and dropped into HTML it just renders as %3C garbage. JSON.stringify handles the quote and the backslash but passes < through, which is precisely what </script> needs. Each column is correct for one context and wrong for the other two.

The encode-free alternative

When you are already in JavaScript building DOM, the cheat sheet’s strongest recommendation is not an encoder: “refactor your code to remove references to unsafe sinks like innerHTML, and instead use textContent or value.” element.textContent = userInput creates a text node; the string is never parsed as HTML, so there is nothing to encode and no double-encoding to get wrong. setAttribute('title', v), style.color = v, and new URLSearchParams({ q: v }) likewise set data through APIs that cannot change context. Encoding is for gluing strings together for a parser to read later; a DOM API skips the parser.

When rendering server-side leaves you gluing strings, the last line of defense is a Content-Security-Policy that refuses to run whatever inline script got through.

What we ship, honestly

Both of our tools implement the HTML row of the table. Read against their source:

The HTML entity encoder escapes the five markup characters (&amp; &lt; &gt; &quot; and decimal &#39; for the apostrophe), plus twenty-one typographic and symbol characters by name — &nbsp;, &copy;, &reg;, &trade;, &hellip;, &ndash;, &mdash;, the four curly quotes, &laquo; &raquo;, &middot;, &deg;, &plusmn;, &times;, &divide;, &ne;, &le;, &ge;. Every other code point above 127 becomes a decimal reference (é → &#233;, an emoji → &#128512;), and ASCII passes through. So yes, it escapes all non-ASCII, always; there is no named-versus-numeric toggle and no hex output. Decoding is the weaker direction: it knows only those 26 names (&apos;, &eacute;, &euro; and the rest of the 2,231-entry WHATWG table come back untouched), accepts decimal and hex references (either x or X), and requires the semicolon. It decodes in a single pass, so &amp;lt; returns &lt; exactly as a browser would — which makes it a safe way to inspect what double-encoded markup will actually display. (Writing this post caught the earlier version decoding sequentially and returning < there; that is fixed, with a regression test.)

String Escape offers five modes: JSON (JSON.stringify, outer quotes stripped), HTML (the same five characters, &#39; for the apostrophe, no other characters touched), URL (encodeURIComponent), REGEX (backslash before .*+?^${}()|[]\), and SQL (single quotes doubled — the standard-SQL rule only, not MySQL backslash escaping; parameterize instead). Its HTML unescape direction uses the browser’s own parser, so it decodes the full named table correctly. Gaps: no dedicated JavaScript mode, no CSS mode, and the JSON mode is JSON.stringify verbatim — it does not convert </ to <\/, so its output is a valid string literal but not yet safe to inline in a <script> element.

The short version

The assumptionThe mechanism
”I escaped it, so it’s safe”Safe for one parser. HTML, JS, URL, and CSS each have different break-out characters
”&apos; is the entity for a single quote”Only in HTML5 and XML; HTML 4.01 has no apos — use &#39; or &#x27;
”&amp;amp; on the page means the encoder is broken”It ran twice. Store raw, encode once, at output
”HTML-encoding works in onclick”The attribute is entity-decoded before the JS runs — the encoding is erased
”HTML-encoding works in <script>”Entities aren’t decoded there, and backslash + newline are untouched; a lone \ breaks out
”JSON.stringify makes it safe to inline”It leaves </script> intact; replace </ with <\/
”Unquoted attributes just need encoding”A space ends an unquoted value — quotes are what make five characters enough
”I need an encoder”In DOM code, textContent and setAttribute need no encoding at all

To see it yourself, put one payload through the HTML entity encoder, each mode of String Escape, and the URL encoder, and compare what survives. When inspecting someone else’s markup, the HTML formatter makes an injected attribute easier to spot than a minified one-liner, JS Formatter does the same for a script block shipped as a single line, and HTML Preview renders the markup in a sandboxed iframe so you can see whether &lt;img src=x onerror=…&gt; comes back as text or as a tag. The CSP builder writes the header that catches whatever the encoders missed. A related trap — characters that survive every encoder because they render as nothing — is covered in invisible Unicode characters.