Escaping HTML entities sounds like a boring housekeeping task until you ship a bug where someone's name breaks your page layout — or worse, until a <script> tag in a comment starts running in your users' browsers. It's one of those fundamentals that quietly separates code that holds up in production from code that gets a CVE.

What escaping actually does

HTML has a handful of characters that mean something structural to the parser. When you put those characters into a page as plain text, the browser needs a way to tell "this is content" apart from "this is markup." That's what entities are for.

The core five you care about:

  • < becomes &lt;
  • > becomes &gt;
  • & becomes &amp;
  • " becomes &quot;
  • ' becomes &#39; (or &apos;)

Escape those consistently and the browser renders the literal characters instead of interpreting them. A user typing 1 < 2 sees exactly that, rather than the parser choking on a phantom tag.

When you need it

The short rule: escape any data that didn't come from you before it lands in HTML. That covers a lot of ground.

  • User-submitted content — comments, usernames, bios, search queries echoed back on the page.
  • Data pulled from a database that originally came from users.
  • Third-party API responses you render directly.
  • Error messages that include input values.

The classic failure is reflected XSS. You have a search page that shows "Results for: whatever they typed". Someone types <script>fetch('/steal?c='+document.cookie)</script>, and if you drop that straight into the DOM, it executes. Escaping neutralises it into harmless text.

Context matters more than people think

Here's the part that catches experienced developers: escaping isn't one-size-fits-all. The correct escaping depends on where in the document the value goes.

HTML body context

Plain text between tags. Escape the five characters above. This is the common case.

Attribute context

Values inside quoted attributes need " escaped so someone can't break out of the attribute. Unquoted attributes are far riskier — a space or > lets an attacker inject new attributes. Always quote your attributes.

Script and style context

HTML escaping does not make data safe inside a <script> block or a style attribute. Those are separate languages with their own escaping rules. Never try to shove user data into inline JavaScript with HTML entities and call it done — it isn't.

URL context

Values going into href or src need URL encoding, not HTML escaping, and you should validate the scheme too (block javascript: URIs).

Common gotchas

  • Double escaping. Escape a string that's already escaped and &amp;lt; shows up literally on the page. This usually happens when two layers both try to be helpful — a template engine and your own code, for instance. Pick one place to escape and be deliberate about it.
  • Escaping too early. Store raw data, escape at render time. If you escape on the way into the database, you can't reliably use that data anywhere else, and you'll fight double-escaping forever.
  • Forgetting the ampersand. If you escape < and > but skip &, your entities get mangled. Escape & first, always.
  • Trusting innerHTML. Setting element.innerHTML = userInput parses the string as HTML. Use textContent instead when you just want text — the browser escapes for you.

A worked example

Say you're rendering a list of comments server-side in Node. Here's a minimal, correct escape function:

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

const comment = '<img src=x onerror=alert(1)> nice post & thanks';
const html = `<li>${escapeHtml(comment)}</li>`;

// Output:
// <li>&lt;img src=x onerror=alert(1)&gt; nice post &amp; thanks</li>

The onerror payload never becomes a real element — it renders as visible text. Notice the regex handles & naturally because we replace each character in a single pass, avoiding the ordering trap.

On the client, you often don't even need a helper:

const li = document.createElement('li');
li.textContent = comment; // browser escapes automatically
list.appendChild(li);

Do you need to hand-roll this?

Mostly, no. Modern template engines escape by default — React's JSX, Vue's mustache syntax, Handlebars, Jinja2, ERB with the right helpers. The danger is in the escape hatches: dangerouslySetInnerHTML, v-html, the triple-brace {{{ }}} in Handlebars. Those bypass escaping entirely, so treat every one as a decision that needs justifying.

When you're debugging output, converting a config snippet for a blog post, or just want to see what a string looks like escaped, a quick converter beats writing a throwaway script. Our HTML Escape Tool handles this both ways — paste raw markup to escape it, or paste entities to decode them back — which is handy for spotting double-escaping issues.

The takeaway

Escape untrusted data at render time, match the escaping to the context it lands in, and lean on your framework's defaults instead of fighting them. The five-character body escape covers the vast majority of cases, but the moment your data heads into an attribute, a script block, or a URL, stop and think about which rules actually apply. Getting this right is cheap. Getting it wrong ends up in a security report.

Photo by Pankaj Patel on Unsplash