There Is No Such Thing as "Escaping" — Only Escaping For Something
Replacing < with < makes a value safe inside HTML text. Put that same escaped value inside an onclick handler, a <script> block, or an href, and it is still a cross-site scripting vulnerability. The escaping you need depends entirely on where the value lands.
The short version
- HTML escaping protects one context — element text. It does nothing for JavaScript, URL or CSS contexts.
- An unquoted HTML attribute can be escaped out of with a plain space. Always quote attributes.
- Inside
<script>, the string</script>ends the block even in the middle of a JavaScript string literal. - Escape on output, not on input. At input time you do not yet know which context the value will be rendered into.
The five characters, and the one context they cover
HTML escaping means replacing characters that have structural meaning with entity references:
| Character | Entity | Why |
|---|---|---|
& | & | Starts an entity reference — must be escaped first |
< | < | Starts a tag |
> | > | Ends a tag |
" | " | Ends a double-quoted attribute |
' | ' | Ends a single-quoted attribute |
Two details matter. The ampersand must be replaced first, or you will re-escape the semicolons of your own entities and produce &lt;, which renders visibly as <. And for the apostrophe, prefer the numeric ' over ' — the named form was not in HTML 4 and is not recognised by some legacy parsers.
Do this correctly and a value is safe in exactly one place: as text between tags. Everywhere else needs different treatment.
The five contexts
1. HTML element text — escaping works
<p>Hello, {{ name }}</p>
Escape the five characters and you are done. This is the only context where "HTML escaping" is a complete answer.
2. Quoted attribute value — escaping works, if quoted
<input value="{{ name }}"> <!-- safe when escaped -->
<input value={{ name }}> <!-- unsafe even when escaped -->
In the unquoted form, the attribute value ends at the first whitespace. So a payload needs no angle brackets and no quotes at all:
name = x onfocus=alert(1) autofocus
→ <input value=x onfocus=alert(1) autofocus>
Every character there survives HTML escaping untouched, because none of them are in the list of five. The defence is not more escaping — it is always quoting your attributes. Then the closing quote is the only way out, and that character is escaped.
3. Inside a <script> block — HTML escaping is the wrong tool
This is where most subtle XSS lives, because the intuitive fix makes things worse:
<script>
var name = "{{ name }}";
</script>
HTML-escaping name here does not protect you — and it corrupts the data, because the JavaScript string will literally contain & where the user typed &. Meanwhile the real exits are still open:
- A double quote closes the string literal:
"; alert(1); // - A backslash can escape your closing quote and shift the parse.
- Most importantly, the literal text
</script>ends the script block — even inside a JavaScript string. The HTML tokeniser runs before the JavaScript parser and does not know or care that it is "inside a string".
name = </script><script>alert(1)</script>
→ var name = "</script><script>alert(1)</script>";
the browser sees the block end, then a brand-new script
Do not interpolate into JavaScript source at all. Serialise the value as JSON into a data attribute or a <script type="application/json"> block and read it from the DOM:
<script id="cfg" type="application/json">{{ json_escaped }}</script>
<script>
const cfg = JSON.parse(document.getElementById('cfg').textContent);
</script>
When you must inline JSON, escape <, > and & as <, > and & — valid JSON that cannot close the block.
4. URL context — escaping is not enough, validate the scheme
<a href="{{ url }}">Profile</a>
HTML-escape this and the attribute is well-formed. It is still exploitable, because the danger is not the syntax — it is the scheme:
url = javascript:alert(document.cookie)
url = data:text/html,<script>alert(1)</script>
Neither contains a character that HTML escaping touches. The defence is an allowlist: parse the URL and accept only http:, https:, mailto: — whatever your feature genuinely needs — and reject everything else. Note that a relative URL is safe, so "starts with /" is a useful fast path.
Individual values inside a URL need percent-encoding on top of that, which is a separate concern covered in our URL encoding guide. Two encodings, applied in the right order: percent-encode the component, then HTML-escape the finished attribute.
5. CSS context — avoid entirely
<div style="width: {{ width }}">
CSS can fetch URLs (url(...)) and historically could execute script in some engines. Escaping rules here are genuinely awkward. The practical answer is to never interpolate user input into CSS. Validate against a strict pattern instead — if the value should be a length, confirm it matches ^\d+(px|%|em)$ and reject anything else. An allowlist of permitted values beats any escaping scheme.
One value, five treatments
| Context | Correct handling |
|---|---|
| Element text | HTML-escape the five characters |
| Quoted attribute | HTML-escape, and ensure the quotes are actually there |
| Unquoted attribute | Do not. Quote it. |
| Script block | JSON-serialise with < escapes, or read from the DOM |
| URL attribute | Scheme allowlist, then percent-encode components, then HTML-escape |
| CSS | Pattern-validate against an allowlist; never interpolate freely |
| Event handler attribute | Never. Attach listeners in JavaScript instead. |
Convert between raw text and HTML entities
Layerbit's HTML Escape / Unescape tool converts in both directions locally in your browser — useful for checking exactly which characters a template escaped, and for spotting double-escaped &lt; in output you are debugging.
Escape on output, never on input
A persistent anti-pattern is escaping data as it arrives and storing the escaped form. It fails for three reasons:
- The context is unknown at input time. The same comment may later be rendered as HTML text, as a plain-text email, as a JSON API response, and in a CSV export. Each needs different treatment — and HTML entities in a plain-text email are simply wrong.
- Your data is now corrupted. The stored value is no longer what the user typed. Search, sorting, string length and comparisons all operate on
&instead of&. - Double escaping. When a correctly written template escapes the already-escaped value, users see
&lt;on the page. The usual "fix" is to disable escaping in that template — which removes the real protection everywhere.
Store exactly what the user typed. Escape at the moment of rendering, using the escaping appropriate to the destination. Input validation is still worthwhile — length limits, format checks, rejecting obvious nonsense — but it is a separate concern from output encoding, and it is not a substitute for it.
Escaping versus sanitising
These solve different problems and are not alternatives:
- Escaping renders the value as inert text.
<b>hi</b>displays as the literal characters<b>hi</b>. Correct for usernames, comments, titles — anything that should not contain markup. - Sanitising parses the value as HTML and removes everything not on an allowlist, keeping the rest live.
<b>hi</b>renders as bold text;<script>is stripped. Correct only when users are genuinely meant to submit formatted content.
If you need sanitising, use a maintained library — DOMPurify is the reference implementation — and never a regular expression. HTML parsing is far too irregular for pattern matching; every hand-rolled tag stripper has been bypassed, usually via malformed markup that browsers helpfully "correct" into something executable.
What modern frameworks give you
React, Vue, Angular and Svelte all escape interpolated values in text and attribute positions by default, which eliminates the common cases. The risk concentrates in the documented escape hatches — dangerouslySetInnerHTML, v-html, [innerHTML], {@html} — and in URL attributes, which frameworks generally do not scheme-check for you. A href={userUrl} in React is still a javascript: vector.
In plain DOM code, the equivalent discipline is to prefer textContent over innerHTML. Assigning to textContent cannot create elements, so there is no context to escape out of — the safety is structural rather than dependent on getting a transformation right.
Ask where the value is going
The habit that prevents this whole class of bug is small: before interpolating any value into a template, name the context out loud. Element text? Quoted attribute? Script block? URL? Each answer implies a different treatment, and "I escaped it" is only a meaningful statement once you have said escaped for what.