HTML Escaping 2026: 1 Real Example Stops XSS

It's 2 AM and Your Comment Section Just Ate Itself

You shipped the comment feature on Friday. By Saturday night, a user named "xX_script_kid_Xx" posted what looked like a harmless message: I love this <site>. Except they didn't type <site> — they typed <script>alert('pwned')</script>. Your React component dutifully rendered it. The browser did what browsers do. Now three users are emailing you about a popup that won't go away.

If you build tools for other developers — formatters, validators, JSON inspectors, playground sandboxes — you live in this exact tension. Your users paste untrusted strings into your interface constantly. One missed escape sequence and your carefully built dev tool becomes an attack vector. So let's walk through, from the ground up, how client-side HTML escaping actually works, why hex and decimal entities both exist, and how to implement a bulletproof escape function without reaching for a 50kb library.

The Problem: When Text Becomes Markup

Here's the core issue. HTML is a parsing language. When the browser's tokenizer encounters the character <, it doesn't think "less than sign." It thinks: tag incoming. The same goes for & — the browser sees the start of an entity reference. These characters have dual identities. They're literal text characters and structural markers in the HTML grammar.

When you drop user input directly into the DOM, you're handing the parser characters it interprets as instructions. Consider this string a user submitted to your tool's preview panel:

<img src=x onerror=alert(1337)>

The browser reads this as: "Create an image element. When it fails to load (which it will, because x isn't a real URL), execute alert(1337)." That's a stored XSS payload, sitting inside your developer tool, firing on every page load for every visitor.

The Five Characters That Matter

Not every character needs escaping. Only five create the structural ambiguity that leads to XSS:

  • < — starts a tag
  • > — ends a tag
  • & — starts an entity reference
  • " — delimits attribute values
  • ' — delimits attribute values (single-quoted)

That's it. Escape these five consistently and you've closed the door on the vast majority of injection attacks in an HTML body context. (Context matters — escaping for JavaScript strings, URLs, or CSS requires different rules, but we're focused on the HTML body and attribute context here, which is where most dev tools render user content.)

Why It Happens: The Browser's Tokenizer

To understand escaping deeply, you need to picture what the browser does with your HTML string. The HTML5 specification defines a tokenization process. The tokenizer reads characters one at a time and shifts between states: "data state," "tag open state," "attribute value state," and so on.

When the tokenizer is in data state (rendering visible text) and it reads <, it transitions to tag open state. Now it's looking for a tag name. If the next character is a letter, it commits — it's building a real element. The tokenizer doesn't care whether that < came from your template or from user input. It has no concept of "trusted" versus "untrusted." It just follows the state machine.

This is why escaping works. When you replace < with &lt;, the tokenizer reads & first, transitions to character reference state, consumes lt;, and emits a literal < character as text. The tag-open transition never fires. You've spoken the browser's language and told it: "This is text, not structure."

The Solution: Hex and Decimal Entity Encoding

HTML entities come in two numeric flavors, plus a set of named ones. Let's break them down with real numbers, because the numbers are what make this click.

Every character has a Unicode code point — a numeric identity. The character < has code point 60 in decimal, which is 0x3C in hexadecimal. You can represent it in HTML three ways:

  • Named: &lt;
  • Decimal: &#60;
  • Hex: &#x3C;

All three produce the same rendered character. The browser decodes them identically. So why choose one over the other?

Named Entities: Convenient but Limited

Named entities like &lt;, &gt;, &amp;, &quot;, and &apos; are readable and self-documenting. Most production escape functions use these five names. They're the safest choice because they're unambiguous and supported in every browser since the dawn of time.

One catch: &apos; isn't supported in older IE versions when used in HTML (not XHTML). If your dev tool needs to support legacy browsers, prefer &#39; for the apostrophe.

Decimal Entities: The Numeric Fallback

Decimal entities use the format &#NNN; where NNN is the Unicode code point in base-10. For our five critical characters:

  • < → &#60;
  • > → &#62;
  • & → &#38;
  • " → &#34;
  • ' → &#39;

Decimal entities shine when you're escaping characters that have no named entity — like a zero-width space (U+200B, decimal &#8203;) or a soft hyphen (U+00AD, decimal &#173;). In a dev tool context, if you're building a character inspector or encoding converter, decimal gives you a universal escape format.

Hex Entities: Compact and Developer-Friendly

Hex entities use &#xNNN; format. The same five characters become:

  • < → &#x3C;
  • > → &#x3E;
  • & → &#x26;
  • " → &#x22;
  • ' → &#x27;

Hex is shorter for high code points — &#x200B; versus &#8203;. It also maps directly to the Unicode charts developers already reference, where code points are listed in hex. If your tool displays character data alongside its Unicode identity (like a hex viewer or encoding debugger), hex entities create a consistent mental model.

Building the Escape Function: A Worked Example

Let's make this concrete. Say a user pastes this payload into your tool's input field:

<a href="javascript:alert(1337)">Click me</a>

You need to escape it before injecting it into the DOM via innerHTML or a template sink. Here's a minimal, dependency-free escape function:

function escapeHtml(input) {
  const map = {
    '&': '&',
    '<': '&lt;',
    '>': '&gt;',
    '"': '&quot;',
    "'": '&#39;'
  };
  return String(input).replace(/[&<>"']/g, ch => map[ch]);
}

Notice the order. & must be replaced first. If you escape < first, producing &lt;, and then escape &, you'd turn it into &amp;lt; — double-escaped and broken. The ampersand replacement creates new entities, so it goes first and the subsequent replacements operate on the original angle brackets, which are still in the string.

Let's trace the payload through the function character by character. The input <a href="javascript:alert(1337)">Click me</a> contains:

  • Two < characters → each becomes &lt;
  • Two > characters → each becomes &gt;
  • Two " characters → each becomes &quot;
  • Zero & characters → no change
  • Zero ' characters → no change

The output becomes:

&lt;a href=&quot;javascript:alert(1337)&quot;&gt;Click me&lt;/a&gt;

When the browser renders this, it displays the literal text <a href="javascript:alert(1337)">Click me</a> as visible, harmless characters. No link is created. No JavaScript fires. The alert never runs. Your tool's preview panel shows exactly what the user typed, and nothing executes.

Choosing Hex or Decimal in Your Implementation

If you want to use numeric entities instead of named ones — perhaps for consistency in a tool that also escapes non-ASCII characters — swap the map:

const map = {
  '&': '&#38;',
  '<': '&#60;',
  '>': '&#62;',
  '"': '&#34;',
  "'": '&#39;'
};

Or the hex variant:

const map = {
  '&': '&#x26;',
  '<': '&#x3C;',
  '>': '&#x3E;',
  '"': '&#x22;',
  "'": '&#x27;'
};

All three maps produce functionally identical output. The browser decodes &lt;, &#60;, and &#x3C; to the same character. Pick the style that fits your tool's identity — named for readability, decimal for universality, hex for compactness and alignment with Unicode references.

A Note on Context: Escaping Is Not One-Size-Fits-All

Here's where many developer tools get tripped up. The escape function above handles the HTML body and attribute context. If your tool injects user input into a <script> block, a style attribute, or a URL href, entity escaping alone won't save you. Inside a script tag, &lt; stays as &lt; — it doesn't prevent </script> from closing the tag early.

For developer tools that render user content in multiple contexts, the rule is: escape for the context you're injecting into. HTML body? Use the function above. JavaScript string? Escape backslashes, quotes, and newlines. URL? Use encodeURIComponent. CSS? Restrict to alphanumeric and a short allowlist.

Testing Your Escape: The Payload That Catches Lazy Implementations

Here's a string to throw at your escape function. If the rendered output executes anything, you've got a gap:

<img src=x onerror="alert(1337)">&lt;script&gt;alert(1337)&lt;/script&gt;"onmouseover="alert(1337)

Count the entities your function produces. You should see six replacements in the first segment alone: one <, one >, two ", and the & characters inside the fake &lt;script&gt; that a user might type to test whether your tool double-decodes. The number 1337 is arbitrary — it's a convention in security testing — but it makes the alert recognizable in your browser console if something slips through.

Run the escaped output through your tool's renderer. If you see alert(1337) as visible text, you're safe. If you see a popup, you've got a sink that's bypassing your escape — probably a dangerouslySetInnerHTML, a jQuery .html() call, or a template that re-parses entities.

Wrapping Up

Client-side HTML escaping isn't glamorous, but it's the load-bearing wall between your developer tool and a compromised user session. The mechanism is simple: identify the five characters that shift the browser's tokenizer into a markup-parsing state, and replace them with entity references that force the tokenizer back into text mode. Whether you use named entities for clarity, decimal for universality, or hex for compactness, the principle is the same — you're translating structure into text so the browser renders exactly what the user typed, nothing more.

Keep the escape function small. Test it with real payloads. And always, always escape for the specific context where the input lands. Your 2 AM self will thank you.

Frequently Asked Questions

What is HTML escaping?

HTML escaping is the process of converting special characters into their corresponding HTML entities, like changing `<` to `&lt;`. This ensures that the browser interprets the characters as visible text rather than executable HTML markup, which is a fundamental step in preventing Cross-Site Scripting (XSS) attacks.

How do I prevent XSS on the client side?

To prevent XSS on the client side, you must properly escape any user-generated content before inserting it into the DOM using methods like `innerHTML`. This involves converting dangerous characters such as `<`, `>`, and `"` into their respective hex or decimal HTML entities so they cannot execute as malicious scripts.

Which HTML characters need to be escaped for XSS prevention?

The five essential characters to escape are the ampersand (`&`), less-than (`<`), greater-than (`>`), single quote (`'`), and double quote (`"`). Converting these into HTML entities ensures that attackers cannot break out of existing HTML tags or attributes to inject malicious JavaScript.

What is the difference between hex and decimal HTML entities?

Decimal HTML entities use base-10 numbers to represent characters (e.g., `<` becomes `&#60;`), while hex entities use base-16 numbers (e.g., `<` becomes `&#x3C;`). Both formats are fully supported by modern browsers and offer identical protection against XSS attacks when implemented correctly.

How to escape HTML in JavaScript?

In JavaScript, you can escape HTML by creating a mapping of special characters to their corresponding entities and using the `replace()` method with a regular expression. Alternatively, you can leverage native browser APIs like `textContent` instead of `innerHTML` to safely insert untrusted data without manual encoding.

What are HTML hex entities?

HTML hex entities are a way to encode special characters using hexadecimal values, formatted as `&#xHHHH;` where HHHH is the hex code. For example, the less-than sign (`<`) can be represented as `&#x3C;` to prevent the browser from parsing it as the start of an HTML tag.

What are HTML decimal entities?

Decimal entities represent characters using their decimal Unicode code points, formatted as `&#DDDD;` where DDDD is the decimal number. For instance, the greater-than sign (`>`) is encoded as `&#62;`, ensuring the browser treats it as plain text instead of closing an HTML tag prematurely.

Is escaping HTML enough to prevent XSS?

While escaping HTML is a critical first line of defense, it must be applied correctly based on the context where the data is inserted, such as HTML body, attributes, or JavaScript variables. For complete protection, escaping should be combined with other security measures like Content Security Policy (CSP) and server-side input validation.

Should I escape HTML on the client or server side?

You should ideally escape HTML on the server side before rendering the initial page, but client-side escaping is absolutely essential when dynamically updating the DOM with user input. Always use safe DOM APIs like `textContent` or `setAttribute` instead of `innerHTML` whenever possible to minimize XSS risks.

How to encode special characters in HTML?

You can encode special characters in HTML by replacing them with named entities (like `&lt;`), decimal entities (like `&#60;`), or hex entities (like `&#x3C;`). Using a robust client-side utility function to automate this replacement ensures that all dangerous characters are consistently encoded across your application.