HTML escaping is one of those topics that feels beneath you until the day user input renders as actual markup and someone pops an alert box on your production site. It's not glamorous, but getting it right is the difference between a safe, correctly-rendered page and a broken (or hacked) one.
What escaping actually does
HTML uses a handful of characters as structural syntax: < opens a tag, > closes it, & starts an entity, and quotes delimit attribute values. When you want those characters to appear as literal text rather than be interpreted as markup, you escape them into entities.
The core mappings you'll use constantly:
&→&<→<>→>"→"'→'(or')
Escape the ampersand first. If you replace < with < before handling &, you'll double-escape and end up with &lt; on the page. Order matters.
When you actually need it
The rule of thumb: escape any data that isn't trusted HTML the moment it crosses into an HTML context. That covers a lot of ground.
Rendering user input
Comments, usernames, search terms, form values echoed back — anything a user typed. If someone sets their display name to <img src=x onerror=alert(1)> and you drop it straight into the DOM, you've got a stored XSS vulnerability. Escaping neutralises it: the browser renders the literal text instead of executing a tag.
Displaying code or markup as text
Documentation sites, tutorials, and this very blog post need to show HTML snippets without the browser interpreting them. A <pre><code> block full of example tags only works if those tags are escaped.
Building attribute values
Injecting data into an attribute is its own hazard. A value containing a quote can break out of the attribute entirely:
<input value="" onmouseover="steal()">Escape quotes in attribute context and that attack collapses.
Common gotchas
Escaping the wrong context
HTML escaping is not universal escaping. Data going into a URL needs URL encoding. Data going into a JavaScript string needs JS escaping. Data going into a CSS context needs CSS escaping. Applying HTML entity escaping to a value that lands inside an inline <script> block won't protect you — the browser parses script content differently. Match the escaping to the destination.
Double escaping
Already-escaped content getting escaped again is the most common visible bug. Users see & or < on the page where an & or < should be. This usually happens when a value passes through two layers that both escape — say a template engine plus a manual helper. Pick one layer to own escaping and keep raw data raw everywhere else.
Trusting your framework blindly without understanding it
Modern frameworks escape by default, which is great. React escapes text in JSX. Jinja2 and Twig auto-escape. But the escape hatches — dangerouslySetInnerHTML, |safe, v-html — turn protection off. Every use of those is a place you need to be certain the content is trusted or sanitised.
Escaping is not sanitising
These solve different problems. Escaping makes everything render as text. Sanitising allows some HTML through while stripping dangerous parts — useful for rich-text editors where you want <b> and <a> but not <script>. If you need users to submit real formatted HTML, reach for a sanitiser like DOMPurify, not an escaper.
A short worked example
Say you're building a comment widget without a framework. A naive version:
function renderComment(text) {
container.innerHTML = '<p>' + text + '</p>';
}
renderComment('<img src=x onerror=alert(document.cookie)>');That executes the injected script. Here's the fixed version with a small escape helper:
function escapeHtml(str) {
return str
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
}
function renderComment(text) {
container.innerHTML = '<p>' + escapeHtml(text) + '</p>';
}Now the comment renders as the literal string <img src=x ...> — visible, harmless, inert.
Better still, when you control the structure, skip string concatenation entirely and let the browser do the escaping for you:
const p = document.createElement('p');
p.textContent = text; // textContent never interprets HTML
container.appendChild(p);Using textContent (or innerText) sidesteps the whole problem because it never parses its input as markup. Prefer this whenever you're inserting plain text — it's harder to get wrong than remembering to escape.
Quick checking and ad-hoc conversions
When you just need to escape a block of markup for a blog post, a bug report, or a config file — or verify what a string looks like after escaping — a browser-based converter beats writing a throwaway script. Our HTML Escape Tool handles both directions, so you can paste raw HTML in to escape it or paste escaped text in to decode it and confirm what the browser will actually render.
The short version
Escape untrusted data at the point it enters an HTML context, and match the escaping to that context. Handle the ampersand first, don't escape twice, and remember escaping isn't the same as sanitising. When you can, let textContent or your framework's default escaping carry the weight — the safest escaping is the kind you don't have to remember to apply.
It's a small discipline, but it quietly prevents a large category of rendering bugs and security holes. Worth building into muscle memory.
Photo by Florian Olivo on Unsplash