HTML Escaping Is Context-Sensitive — Text Escape ≠ Attribute Safe

(Updated: July 16, 2026 ) HTML escape XSS security sanitization webdev

A ticket says “we escape user input.” The sink is element.innerHTML = "<img src=x onerror=...>" inside an attribute that was only passed through a text escaper. XSS still fires. Escaping is not a boolean. It is a function of where the string lands in the document.

Context matrix

SinkThreat shapeDefense
HTML text nodeInjected tagsEscape & < > (and quotes if needed)
Double-quoted attributeBreak out via "Escape & " < > (policy-dependent)
URL in href/srcjavascript:, data URLsAllowlist schemes + encode
CSS contextexpression() / url()Avoid untrusted CSS; strict encoding
JS string in <script>Break ' / " / </script>JSON.stringify into JS, or avoid
HTML embedding JSON</script> in dataSafer: separate JSON script type or escape

A single escapeHtml that replaces < with &lt; is correct for many text nodes and wrong for assembling onclick="..." or raw script blocks.

Framework comfort vs server templates

React, Vue, and Svelte reduce accidental text XSS. They do not erase:

  • Markdown rendered to HTML without a sanitizer
  • CMS fields stored as HTML
  • Email templates concatenating strings
  • PDF/HTML exporters
  • insertAdjacentHTML “just this once”

Server-side templates (Jinja, ERB, Mustache with wrong {{{ }}}) are classic mix-ups: auto-escape on by default until someone disables it for a “trusted” partial that includes user HTML.

Unescape is a footgun with a badge

Support tools sometimes “fix mojibake” by unescaping entities twice. &amp;lt;script&amp;gt; becomes active markup after enough rounds. Store canonical plaintext or canonical safe HTML — not half-escaped mush. If you must debug entities, use a local HTML escape / unescape tool on samples, not on production traffic logs pasted into random sites.

Payload tests worth keeping in QA

  • "><img src=x onerror=alert(1)> in profile name fields
  • javascript:alert(1) in website URL fields
  • </script><script>alert(1)</script> in JSON-in-script templates
  • Attribute breakout with smart quotes and Unicode homoglyphs

Automated scanners help; intentional unit tests on your encoder per context help more.

Policy that scales

  1. Default: treat all user input as plaintext; escape at the boundary of HTML serialization.
  2. Rich text: sanitize with a maintained library (allowlist tags/attrs), never regex.
  3. URLs: parse, allowlist https: (and mailto: if needed), reject others.
  4. Never build JS source from concatenation of user strings; pass data via json_encode / JSON.stringify.

Double encoding and display bugs

Support tickets about &amp;amp; in titles usually mean someone escaped on write and on read. Pick a single boundary: store plaintext, escape at render; or store sanitized HTML and never escape again. Mixing both produces ugly UI and encourages the next developer to unescape “to fix it,” which reopens XSS.

When migrating legacy data, write a one-off that detects entity soup and normalizes to plaintext under review — do not silently unescape in the request path.

Content Security Policy as a backstop

CSP (script-src, object-src, strict dynamic) reduces blast radius when a sink is missed, but it does not excuse wrong escaping. Inline event handlers and javascript: URLs may still hurt users even when external scripts are blocked. Treat CSP as defense in depth, not as permission to concatenate HTML.

Train code review to ask “what context?” whenever they see innerHTML, template raw HTML, or string-built href. A local escape playground helps show how the same payload looks after text vs attribute encoding — useful for onboarding, useless as a substitute for framework-safe APIs.

Escaping textbooks show a five-character table. Real apps have five contexts. Match the encoder to the sink, or the sink will match the attacker.