Every time you take a string from somewhere untrusted and drop it into a web page, you're making a bet. Escape it properly and you're fine. Skip it and you might be shipping a cross-site scripting hole, or at best rendering garbage where a user typed <3.
HTML escaping isn't complicated, but it's easy to get subtly wrong. Let's walk through what it is, when it matters, and the mistakes I still see in code reviews.
What HTML escaping actually is
HTML uses a handful of characters to mean something structural: < and > open and close tags, & starts an entity reference, and quotes delimit attribute values. When your content contains those characters literally, the browser can't tell whether you mean "draw this text" or "this is markup."
Escaping replaces those characters with entity references so the browser renders them as text instead of interpreting them:
&becomes&<becomes<>becomes>"becomes"'becomes'(or')
So the string <script>alert(1)</script> turns into <script>alert(1)</script>, which shows up on the page as literal text rather than executing.
When you actually need it
The rule of thumb: escape any dynamic value at the point it enters an HTML context. That's the key phrase — at the point it enters an HTML context. A few concrete cases:
- User-generated content: comments, usernames, bios, search queries echoed back on the page.
- Data from a database or API that you don't fully control.
- Error messages that include user input (a classic reflected XSS vector).
- Anything you're building into an HTML template manually with string concatenation.
If you're using a modern templating engine or framework, a lot of this happens for you. React escapes values in JSX by default. Jinja2, Twig, and most server-side templates auto-escape unless you explicitly turn it off. The danger zone is when you bypass that: dangerouslySetInnerHTML, {{{ }}} in Handlebars, or innerHTML = in raw JavaScript.
A short worked example
Say you're building a search results page and you want to show the user what they searched for. The naive version:
// DON'T do this
const query = req.query.q;
res.send(`<h2>Results for ${query}</h2>`);If someone visits ?q=<script>document.location='http://evil.example'</script>, that script runs in every visitor's browser who clicks the crafted link. Reflected XSS, delivered.
The fix is to escape query before it hits the HTML:
function escapeHtml(str) {
return str
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
}
const query = req.query.q;
res.send(`<h2>Results for ${escapeHtml(query)}</h2>`);Notice the order: & gets replaced first. If you escape it last, you'll double-escape the ampersands you just introduced, turning < into &lt;, which renders as the literal text <. That ordering bug is one of the most common ones I see in hand-rolled escapers.
Common gotchas
Context matters more than you think
HTML escaping is correct for HTML element content and quoted attribute values. It is not sufficient for every context. If you're injecting a value into a URL, an inline onclick handler, or a <script> block, HTML escaping won't fully protect you. Each context has its own encoding rules — URL encoding, JavaScript string encoding, CSS encoding. Putting a user value straight into <a href="..."> without URL-aware handling can still let through javascript: URIs.
Double escaping
If your framework already auto-escapes, calling your own escape function on top of it produces mangled output. Users see & and < in the page. Decide where escaping happens and don't stack it.
Escaping when storing instead of when rendering
A tempting shortcut is to escape data before saving it to the database. Resist it. The same stored value might later be rendered as plain text in an email, sent through an API as JSON, or shown in a mobile app that expects raw characters. Store the original, escape on output for the specific context you're rendering into.
Forgetting attribute quotes
Escaping quotes only helps if your attributes are actually quoted. <input value=${userValue}> without quotes can be broken out of with a space and an event handler, even if you escaped the quote characters. Always quote your attributes.
Doing it quickly during development
When you're debugging, pasting a JSON blob into a template, or writing documentation that needs to show HTML as text, you often just need to escape a chunk of markup once and move on. For that I keep a browser tab open with an HTML Escape Tool — paste in the raw string, get the escaped version, done. It's faster than writing a throwaway script, and handy for the reverse direction when you need to unescape something too.
The takeaway
Escaping HTML entities is about being explicit: telling the browser "treat this as text, not markup." Lean on your framework's auto-escaping wherever you can, escape at the point of output rather than storage, and remember that HTML context isn't the only context — URLs and JavaScript need their own handling.
Get the ampersand ordering right if you ever write your own escaper, quote your attributes, and don't double-escape. Do those four things and you've eliminated the vast majority of the XSS bugs that make it into production.
Photo by Florian Olivo on Unsplash