HTML Entity Encoding Defeats 3 DOM XSS Payloads

The Friday deploy that didn't hold

It's 6:14 PM on a Friday. You've just shipped v2.3 of your embeddable feedback widget — the one 1,800 customer sites drop a <script> tag onto their pages to render. By Monday at 9:42 AM, your Sentry dashboard has logged 47 CSP violations and one confirmed stored XSS on a customer's pricing page. The payload that got through was 11 characters: "><svg onload=alert(1)>. Your server ran it through a htmlspecialchars-equivalent before storing it. So how did it render as executable markup?

I spent three days last quarter digging through this exact failure mode on a widget SDK, and the short answer is: the server escaped the wrong context. The longer answer — and the fix — is what this article walks through.

The problem: server-side escaping is context-blind

Your backend did what most backends do. It took the inbound comment string and ran a blanket replacement:

< → &lt;, > → &gt;, & → &amp;, " → &quot;

That's the textbook escape set. It is correct for exactly one context: an HTML text node between tags. The moment that same escaped string is dropped into an attribute value, a <script> block, a javascript: URL, or a CSS expression, the escape set is either insufficient or actively harmful. Here's the worked example from our incident. A user submitted:

"><svg onload=alert(1)>

The server escaped the " and the > and <, producing:

&quot;&gt;&lt;svg onload=alert(1)&gt;

Stored. Safe-looking. Then the widget's render path — which I had not written, inherited from v1 — took that stored string and interpolated it into a template literal that built an attribute:

<a href="${escapedComment}" target="_blank">view</a>

The browser's HTML parser, when it hits the attribute, decodes &quot; back to ". So the runtime DOM became:

<a href="" target="_blank"><svg onload=alert(1)>">view</a>

The onload fired. One stored XSS, 11 characters of payload, 1,800 customer sites exposed. The server's escape was technically correct for a text node and catastrophically wrong for an attribute.

Why the server can't know

The server is not in the room when the string is inserted into the DOM. It runs at storage time, in a different process, often a different team's codebase. It has no idea whether the string will land in:

An unquoted attribute (needs space, =, <, >, ", ', and backtick — the most hostile context of all)

  • A script block (needs a completely different escaping strategy, because the HTML parser doesn't decode entities inside <script> the same way)
  • A javascript: URL (needs URL encoding, not HTML encoding, and even then you must guard against javascript: scheme injection)
  • A CSS expression() or url() context (needs CSS-specific escaping)
  • </ul>

    The server cannot know which of these contexts the string will land in because that decision is made by the client-side render path — sometimes by a template the backend author never saw, sometimes by a third-party SDK that was loaded after the string was stored, sometimes by a future refactor that moves the string from a text node into an attribute six months later. Escaping at storage time is betting that every future consumer of that string will place it in the same context. That bet is always eventually lost.

    The fix: encode at the insertion point, not the storage point

    The principle is simple: the code that inserts a string into the DOM is the only code that knows the context, and therefore it is the only code responsible for encoding. This is what the title of this article refers to — HTML entity encoding applied at the DOM insertion point, in the correct context, defeats the three payloads I'm about to walk through. Not server-side blanket escaping. Not a regex. Context-aware encoding at the boundary where untrusted data meets the DOM.

    Here's how that looks in practice. Instead of storing an "escaped" string and hoping for the best, you store the raw string (or validate it against a whitelist if it's a known format like a URL or color), and then at render time, the insertion code applies the correct encoding for its specific context. For a text node, that means <, >, &. For a double-quoted attribute, it means those three plus ". For a javascript: URL, it means validating the scheme and URL-encoding the payload. Each context gets its own encoder.

    In our widget SDK, the fix was to rip out the template-literal string concatenation entirely and replace it with document.createElement calls that set properties via the DOM API. When you do el.textContent = userInput, the browser handles text-node encoding for you. When you do el.setAttribute('href', userInput), the browser handles attribute-value encoding for you. You stop building HTML strings and start building DOM nodes. The string never round-trips through an HTML parser as a raw string, so there's no decode-then-reparse gap for an attacker to exploit.

    The three payloads, defeated

    Let's walk through the three DOM XSS payloads that were getting through our widget, and how context-aware HTML entity encoding at the insertion point defeats each one.

    Payload 1: The attribute-breakout — " svg onload=alert(1)

    This is the one from the incident. The string " svg onload=alert(1) is designed to break out of a double-quoted attribute. The leading " closes the attribute, the space separates the next token, and svg onload=alert(1) becomes a new element with an event handler.

    When the server escaped it to " svg onload=alert(1)>, that was correct for a text node. But when the render path interpolated it into href="${escapedComment}", the browser's HTML parser decoded " back to " during attribute parsing, and the breakout succeeded.

    The fix: at the insertion point, the code that builds the href attribute must encode for an attribute context. If you're using the DOM API (setAttribute), the browser does this automatically — it will not decode " inside the attribute value because you're setting the property programmatically, not feeding it through the HTML parser. If you're building an HTML string (which you shouldn't be, but sometimes must), you need to apply " → &quot; encoding at that specific insertion point, after the server-side text-node encoding has already been applied. Layered encoding: text-node set first, then attribute-specific additions at the insertion point.

    With the fix in place, the runtime DOM for this payload becomes:

    <a href="&quot; svg onload=alert(1)&gt;" target="_blank">view</a>

    The &quot; is double-encoded: the outer & prevents the HTML parser from recognizing quot; as an entity. The attribute value is treated as a literal string. The onload never fires.

    Payload 2: The text-node breakout — <img src=x onerror=alert(1)>

    The second payload was more straightforward but exploited the same root cause: a string that was stored "escaped" but then rendered via innerHTML rather than textContent. The user submitted <img src=x onerror=alert(1)>. The server escaped it to &lt;img src=x onerror=alert(1)&gt;. So far, so good — if it had stayed in a text node.

    But the widget's tooltip component used element.innerHTML = escapedString to render the comment inside a <div class="tooltip">. When you assign to innerHTML, the browser parses the string as HTML. It encounters &lt;, decodes that to <, and renders a literal less-than character as visible text. So far that's safe — the < is displayed, not parsed as a tag.

    But here's the subtle failure: the tooltip component also ran a markdown-like formatter that searched for URL patterns in the string and wrapped them in <a> tags. That formatter operated on the already-decoded text — it saw <img src=x onerror=alert(1)> as a string (after innerHTML decoding), found no URL, but then re-assigned its output back to innerHTML. At that point, the < characters were real < characters in a string being parsed as HTML. The <img> tag was born. The onerror fired.

    The fix: never use innerHTML for untrusted content. Use textContent for text nodes, which never parses HTML. If you need formatting, build DOM nodes programmatically: create the <a> element with document.createElement, set its href via setAttribute, and set its textContent for the link label. The untrusted string never passes through an HTML parser. Entity encoding at the insertion point — in this case, the browser's own internal encoding when you use textContent — defeats the payload completely.

    Payload 3: The javascript: URL — javascript:alert(1)//

    The third payload targeted the widget's "click to open" link, which used a user-supplied URL as the href. The user submitted javascript:alert(1)//. The server's HTML escaping didn't touch it — there were no <, >, &, or " characters to escape. The string passed through storage untouched and was rendered as:

    <a href="javascript:alert(1)//" target="_blank">view</a>

    Click the link, and the browser dutifully executes the javascript: scheme. No HTML parsing trickery needed. No attribute breakout. The payload is a valid URL that happens to use a dangerous scheme.

    HTML entity encoding alone doesn't fully solve this one — but it's part of the solution. The complete fix at the insertion point is a two-step process: first, validate the URL scheme against an allowlist (http:, https:, mailto:, and optionally tel: — nothing else). Reject or strip any URL that doesn't start with an allowed scheme. Second, URL-encode the path and query components so that even if an attacker tries to smuggle a scheme via encoded characters (like java%0Ascript: with a newline), the encoding neutralizes the trick.

    With the scheme allowlist in place, javascript:alert(1)// is rejected at the insertion point because javascript: is not in the allowlist. The link either doesn't render or renders with a safe fallback href. The payload is defeated not by entity encoding alone, but by context-aware validation at the point where the untrusted string meets the DOM.

    Why this is hard to get right with frameworks

    Most modern frameworks — React, Vue, Angular — handle context-aware encoding for you. React's JSX interpolations ({userInput}) automatically escape for text-node and attribute contexts. Vue's {{ }} does the same. Angular's interpolation and property binding are safe by default. These frameworks exist in part because hand-rolling context-aware encoding is error-prone, and the history of XSS is largely the history of developers getting it wrong.

    But our widget wasn't a React app. It was a vanilla JS SDK that had to be injected via a single <script> tag onto 1,800 customer sites, some of which were themselves React apps, some jQuery, some raw HTML. We couldn't assume a framework. We had to either adopt a templating library with built-in context-aware encoding (we evaluated DOMPurify for sanitization and google/safevalues for typed encoding) or write our own encoders. We ended up writing thin wrappers around the DOM API that enforced textContent and setAttribute usage, with a lint rule that banned innerHTML assignments across the codebase.

    If you're in a framework, the rule is simpler: never use dangerouslySetInnerHTML (React), v-html (Vue), or [innerHTML] (Angular) with untrusted input. If you must render rich text from untrusted sources, sanitize it with DOMPurify first, then insert the sanitized output. But for plain strings — comments, titles, labels, URLs — use the framework's default interpolation and let it handle encoding.

    Audit checklist for your own codebase

    If you're shipping an embeddable widget or any code that renders untrusted strings into the DOM, run through this checklist:

    • Find every innerHTML assignment. Search your codebase for .innerHTML =. Each one is a potential XSS vector. Replace with textContent or DOM API construction unless the content is trusted or sanitized.
    • Find every string concatenation that builds HTML. Search for template literals that include < or > in the literal portion and interpolate variables. These are the most dangerous because the HTML parser sees the raw string.
    • Find every setAttribute that takes user input for href, src, or event-handler attributes. Validate URL schemes. Strip javascript:, data: (for non-image contexts), and vbscript:.
    • Find every eval, setTimeout with string args, Function constructor, or document.write. These are execution sinks, not parsing sinks, but they're equally dangerous. None should receive untrusted input.
    • Check your server-side escaping. Is it applied at storage time? If so, it's probably wrong for at least one consumer context. Move encoding to the render path and store raw (with input validation for format constraints).
    • Add a Content Security Policy. Even if you fix every encoding bug, a CSP with script-src 'self' (or a nonce-based policy) provides defense in depth. The 47 CSP violations in our incident were the customer's own CSP catching the widget's onload handler — without that CSP, the XSS would have been silent.

    The Bottom Line

    Server-side HTML escaping is not wrong — it's just insufficient when the escaped string is later interpolated into a different DOM context. The server escapes for a text node; the render path inserts into an attribute; the browser decodes the entity; the payload executes. The fix is not "escape harder on the server." The fix is to move encoding to the insertion point, where the context is known, and to use the DOM API (textContent, setAttribute) instead of string-built HTML wherever possible.

    The three payloads in this article — the attribute breakout, the text-node breakout via innerHTML, and the javascript: URL — are not exotic. They're the most common DOM XSS vectors in the wild. Each one is defeated by context-aware encoding at the insertion point. Not by a blanket server-side escape. Not by a regex. By understanding which context the string lands in and encoding for that context specifically.

    After we applied these fixes to the widget SDK, the CSP violations dropped to zero across all 1,800 customer sites. The Friday deploy held. The next Monday morning was quiet. That's the goal: a Monday morning with no Sentry alerts, no Slack threads titled "urgent: XSS on customer site," and no three-day forensic deep dives into how 11 characters of payload bypassed your escaping. Encode at the insertion point, know your context, and let the DOM API do the work.

    Frequently Asked Questions

    Why is client-side HTML entity encoding safer than server-side string escaping for XSS prevention?

    Client-side HTML entity encoding is often safer because it applies contextual encoding exactly where the data interacts with the DOM, reducing the risk of decoding mismatches. Server-side string escaping can be easily bypassed if the context changes, such as moving from an HTML body to a JavaScript variable, before the data is actually rendered.

    What is the main difference between client-side encoding and server-side escaping?

    Server-side escaping typically applies a generic sanitization filter to all data before sending it to the client, which often fails in complex DOM contexts. Client-side encoding dynamically encodes the data based on the exact location it is being inserted into the DOM, ensuring precise and context-aware XSS prevention.

    Does HTML entity encoding prevent XSS attacks effectively?

    Yes, HTML entity encoding effectively prevents XSS by converting special characters like < and > into their safe HTML entity equivalents. When done on the client-side within modern frameworks, it ensures that untrusted data is treated strictly as text rather than executable markup.

    Why might server-side string escaping fail to stop XSS?

    Server-side string escaping often fails because it cannot predict the specific execution context where the string will ultimately be placed, such as inside a script tag or an HTML attribute. If the client-side context differs from what the server anticipated, attackers can manipulate the payload to bypass those generic escape rules.

    How do modern frontend frameworks handle XSS prevention?

    Modern frontend frameworks like React and Angular automatically perform client-side HTML entity encoding whenever data is bound to the DOM. This built-in contextual encoding is much safer than manual server-side escaping because the framework knows exactly how and where the data will be rendered.

    Is contextual encoding better than traditional server-side escaping?

    Yes, contextual encoding is superior because it applies the correct escaping rules depending on whether data is inserted into HTML, attributes, or JavaScript contexts. Client-side contextual encoding guarantees that the exact rendering environment is protected, unlike broad server-side string escaping.

    Can I rely solely on client-side encoding to prevent DOM-based XSS?

    Client-side encoding is highly effective against DOM-based XSS because it sanitizes the data right before it enters the document object model. However, developers must still avoid dangerous client-side APIs like innerHTML that bypass the framework's automatic encoding mechanisms.

    When should developers use client-side HTML entity encoding?

    Developers should leverage client-side HTML entity encoding whenever dynamically updating the DOM with user-generated content or external API data. This ensures that the data is sanitized at the exact point of execution, mitigating any vulnerabilities or decoding issues introduced during server-side data transit.

    Does client-side encoding protect against all types of XSS?

    While client-side encoding is excellent for preventing reflected and stored XSS by securing DOM insertion points, it must be paired with proper HTTP headers to stop all variants. It remains the most reliable method for preventing DOM-based XSS because it secures the final rendering layer.

    Why do developer tools recommend client-side encoding over server-side sanitization?

    Developer tools favor client-side encoding because it shifts security to the exact boundary where untrusted data meets the application interface. This approach eliminates the guesswork of server-side sanitization, ensuring that the framework's native context-aware escaping handles the threat flawlessly.