HTML Escaping Is Not XSS Protection. Context Is.
Five entities are enough in an HTML body and useless inside a script tag or an href. What each context actually needs.
Table of contents
- Key takeaways
- What does HTML escaping actually do?
- Why isn't escaping enough on its own?
- Where does no encoding help at all?
- Escaping or sanitising?
- What your framework already does
- Debugging encoding rather than guessing at it
- A checklist
- Frequently asked questions
- Is escaping the same as encoding?
- Do I still need to escape if I use React?
- Which characters do I need to escape in an HTML attribute?
- Does a Content-Security-Policy remove the need to escape?
- Why does my text show & instead of an ampersand?
- Related reading
- References
"We escape all user input" is the most common answer to a question about XSS, and on its own it means very little. Escaping is not one operation. It is five different operations that share a name, and applying the wrong one is indistinguishable from applying none.
The five characters everyone memorises, & < > " ', are the correct answer for exactly one context. Put that same escaped string inside a <script> block, an href, or a style attribute and it protects nothing.
Key takeaways#
- HTML-entity encoding is correct in an HTML body and nowhere else by default.
- OWASP specifies five entities for that context:
&<>"'. - Attributes, JavaScript, CSS and URLs each need a different encoding.
- Some contexts are dangerous. No encoding makes them safe, so never interpolate there.
- Escaping and sanitising are different jobs. Escaping neutralises; sanitising removes.
What does HTML escaping actually do?#
It converts characters that the HTML parser treats as markup into references that render as text. <script> becomes <script>, which the parser reads as four visible characters rather than a tag.
OWASP's list for an HTML body context is exactly five:
| Character | Entity |
|---|---|
& | & |
< | < |
> | > |
" | " |
' | ' |
& goes first, always. Escape it after the others and you double-encode your own output: < becomes <, then the & in that becomes &lt;, and the page shows the literal text < instead of a less-than sign. Every "my page is showing & everywhere" bug is this ordering mistake, or the same escaper applied twice.
Why isn't escaping enough on its own?#
Because the parser that consumes your output changes depending on where the output lands, and each parser has a different set of dangerous characters. HTML entities only mean something to the HTML parser.
OWASP's guidance is a different encoding per context:
| Context | Encoding | Example |
|---|---|---|
| HTML body | The five entities above | < |
| HTML attribute | &#xHH; for all non-alphanumerics | ' |
| JavaScript | \uXXXX | < |
| CSS | \XX or \XXXXXX | \3C |
| URL parameter | Percent-encoding | %3C |
Here is the failure in one line. This value is correctly HTML-escaped and completely unsafe:
<!-- value = ');alert(1);// -->
<script>
var name = '');alert(1);//';
</script>The HTML parser decodes ' back to ' before JavaScript ever sees the source. The escaping is undone by the time it would have mattered, and the quote breaks out of the string. Nothing about the escaping was wrong; it was the wrong escaping for where the value went.
Where does no encoding help at all?#
Some positions in a document are unsafe regardless of what you do to the value. OWASP calls these dangerous contexts and the guidance is not to encode harder but to never put a variable there:
- Directly inside
<script>…</script> - Inside an HTML comment
- Directly inside
<style>…</style> - In an event handler attribute such as
onclickoronerror - In a tag name or an attribute name
- Anywhere that reaches
eval,setTimeoutorsetIntervalas a string
The rule that follows is simple and worth applying literally: data goes in data positions. If a script needs a server value, put it in a data- attribute or a <script type="application/json"> block and read it from the DOM. Do not interpolate it into executable source.
Escaping or sanitising?#
Two different jobs, and reaching for the wrong one is why "we escape everything" sometimes breaks a feature instead of a payload.
Escaping neutralises markup so it renders as text. Use it when the value should never be markup: a username, a comment, a search term echoed back. It is lossless: <b> displays as <b>.
Sanitising parses markup, drops what is not allowed, and keeps the rest. Use it when the value is meant to contain markup, such as a rich-text editor or Markdown that permits raw HTML. It is lossy by design, and that is the point.
Escaping rich text destroys the feature: an editor's output arrives on the page as visible tags. Sanitising a plain username is unnecessary work and a larger attack surface. The question is not which is safer but which one matches what the field is for.
Two rules for the sanitising path:
- Allowlist, never blocklist. Enumerate the tags and attributes you permit. A blocklist is a list of the bypasses someone has already thought of.
- Sanitise on the server, or at minimum again on the server. Client-side sanitising protects the user who typed the value; it does nothing about a request that skipped the editor entirely.
What your framework already does#
Most XSS in modern applications is not a missing escape. It is an explicit opt-out of one.
React escapes interpolated values in JSX automatically, and dangerouslySetInnerHTML is the documented way around it, named to be conspicuous in review. Vue does the same with v-html, Angular with bypassSecurityTrustHtml, and server templates with | raw, {{{ }}} or MarkupSafe. Every one of those is safe only when what it receives has already been sanitised.
None of them help with the contexts above. A framework escapes for HTML; it does not know that this particular string is about to become a URL or a CSS value. <a href={userValue}> in React is not protected against javascript: by anything React does.
A Content-Security-Policy is the layer that catches what slipped through. It is not a substitute for encoding. It limits the damage when encoding fails, which over a long enough period it will.
Debugging encoding rather than guessing at it#
Two symptoms, two different causes, and they are easy to confuse:
- Literal
<visible on the page. Double encoding. Something escaped an already-escaped string, or escaped&last. - Markup rendering where text was expected. No encoding, or a decode after the encode.
The fastest way to tell them apart is to look at what was actually produced. Run the value through the HTML encoder to see what correct output looks like for that string, and the HTML decoder to unwind what you were served and count the layers — decoding twice and getting different readable text each time is the signature of double encoding. The markdown preview is the adjacent case: Markdown permits raw HTML, so anything rendering Markdown from users needs the sanitising path rather than the escaping one.
A checklist#
- Identify the context before choosing an encoder. HTML body, attribute, JavaScript, CSS and URL are five answers, not one.
- Escape
&first, or use a library that does. - Never interpolate into a dangerous context. Use
data-attributes or a JSON block. - Allowlist URL schemes wherever a value becomes an
hreforsrc. - Sanitise on the server for anything that is genuinely rich text.
- Encode on output, not on input. The same value may go to HTML, JSON and a log, and each wants something different. Encoding on the way in commits you to one of them and corrupts the rest.
Step 6 is the one that quietly causes the most damage. Escaping at input time means your database holds < instead of <, which then appears verbatim in every CSV export, email and API response that is not HTML.
Frequently asked questions#
Is escaping the same as encoding?#
In this context the terms are used interchangeably, and both mean transforming characters so a parser treats them as data. "Sanitising" is the one that is genuinely different: it removes content rather than transforming it.
Do I still need to escape if I use React?#
For HTML body content, React handles it. You still need to think about URLs, values that reach dangerouslySetInnerHTML, and anything interpolated into a style or an event handler. The framework escapes for one context and cannot know about the others.
Which characters do I need to escape in an HTML attribute?#
More than the five. OWASP recommends &#xHH; encoding for every character that is not alphanumeric, because unquoted attributes can be terminated by a space, a slash or a backtick as well as by a quote. Quoting attributes and using a library is the practical answer.
Does a Content-Security-Policy remove the need to escape?#
No. It reduces what an injected script can do, blocking external sources and inline execution under a strict policy. But a policy loose enough to run your own inline scripts will run an injected one too. Treat it as the second layer.
Why does my text show & instead of an ampersand?#
Something escaped it twice, or escaped & after the other characters. Decode the value once and see whether you get &; if you do, remove the second encoding pass rather than adding a decode to compensate.
Related reading#
- Base64 Is Not Encryption — the same lesson about a different transform
- encodeURI vs encodeURIComponent — the URL context in detail
- Decoding a JWT Is Not Verifying It
- Check what a value becomes with the HTML Encoder and unwind layers with the HTML Decoder.
References#
Tags
- Security
- Xss
- HTML
- Encoding