HTML Escape — XSS Isn't Only innerHTML

webdev html-escape tools

Teams often treat XSS as “remember to avoid innerHTML.” Then a ticket lands where the markup never used innerHTML at all: a profile name landed in an attribute, a CSS URL, or a javascript: href. Escaping is context-sensitive. The same string needs different encoding depending on where it is injected.

If you only replace < and > and call it done, you are not done.

Contexts that each need their own rules

HTML text content (between tags): escape & < > at minimum so <script> becomes visible text.

HTML attribute values (quoted): escape & < > " ' (and ideally normalize quotes). An unescaped " lets an attacker break out of value="..." and add onerror= handlers.

URL contexts (href, src): escaping HTML entities is not enough. Block dangerous schemes (javascript:, data: in sensitive places) with an allowlist (https:, mailto:). Entity-escaping a javascript: URL still navigates to script in some cases depending on browser parsing history — treat URLs as a separate validation problem.

Inline JavaScript / JSON in script tags: use JSON serialization into JS, not HTML escaping alone. HTML-escaping a string that sits inside <script> is the wrong tool.

CSS: user-controlled CSS is its own nightmare (expression, url(), etc.). Prefer disallowing raw CSS from users.

function escapeHtml(text) {
  return String(text)
    .replace(/&/g, "&amp;")
    .replace(/</g, "&lt;")
    .replace(/>/g, "&gt;")
    .replace(/"/g, "&quot;")
    .replace(/'/g, "&#39;");
}

Use that for text and quoted attributes. For debugging pasted payloads, an HTML escape/unescape tool helps you see what the encoded form should look like — it does not replace framework escaping.

What frameworks actually guarantee

React escapes text children and most attribute values when you use JSX normally ({user.name}). That is not a free pass for:

  • dangerouslySetInnerHTML
  • Building HTML strings yourself
  • href={userControlled} without scheme checks
  • Rendering into non-React sinks (email templates, PDF HTML, server-side string templates)

Server templates (Jinja, EJS, Liquid) each have auto-escape defaults and escape filters that differ for HTML vs JS. Misconfigured auto-escape is still common.

Payloads worth testing in QA

"><img src=x onerror=alert(1)>
'><script>alert(1)</script>
javascript:alert(1)
{{constructor.constructor('alert(1)')()}}

Not every payload applies to every stack. The point is to probe attribute breakout, text injection, and URL schemes separately. If your test suite only greps for <script> in HTML text, you will miss attribute XSS.

SinkDefense
Text nodeHTML escape / framework text binding
AttributeEscape quotes + use quoted attrs
href/srcScheme allowlist + encoding
HTML emailStrict escape + limited tags
Markdown → HTMLSanitizer after render (DOMPurify etc.)

Sanitizing HTML (allowing some tags) is different from escaping (showing tags as text). Pick the product intent first.

FAQ

Does escaping once at the database fix XSS?
No. Store raw (or normalized) data; escape at each output boundary for that context. Escaping on write causes double-escape display bugs and still fails if another channel renders differently.

Is React XSS-proof?
No. It reduces footguns for common JSX text/attrs. Dangerous APIs and URL attrs remain.

When do I need a sanitizer instead of escape?
When users are allowed to submit a subset of HTML (rich text). Escape alone would destroy that feature; sanitize to an allowlist instead.

Escape for the sink you write into. XSS is not a single replace('<', '') away from being solved.