All articles
Encoders & Decoders
39 min readBy DevUtilX Team

HTML Entities: Escaping, Encoding and Preventing XSS

Learn how HTML entities work, which characters to escape, why context matters, and how output encoding, safe DOM APIs and CSP prevent XSS.

HTML Entities: Escaping, Encoding and Preventing XSS

Every web developer eventually meets a page that shows & where an ampersand should be, or worse, a page that runs code an attacker typed into a comment box. Both bugs have the same root: the text that a browser displays and the text that a browser parses as markup are two different things, and somebody confused one for the other. HTML entities, also called character references, are the mechanism that keeps the two apart. This guide is for developers who write templates, build APIs that feed web pages, or review code for security. You will learn what character references are, which characters must always be escaped, how the HTML parser treats sloppy input, why escaping depends on context, how cross-site scripting (XSS) really happens, and which defences work when entity encoding alone does not.

What are HTML entities and character references?

HTML is a text format in which a few characters have a structural meaning. A less-than sign starts a tag, an ampersand starts a character reference, and quotation marks delimit attribute values. When you want one of those characters to appear on the page as plain text, you write a character reference instead. The browser sees the reference, replaces it with the character during parsing, and treats the result as text, never as markup.

The MDN glossary entry for character references describes the idea precisely: a character reference is a sequence that stands for a character and begins with an ampersand. The word "entity" is the older, looser term. Strictly, an entity is a named piece of replacement text defined in SGML or XML, and a character reference is the use of it. In everyday conversation, and in tool names, people say "HTML entities" for all of it, and this article does the same.

The three syntaxes

A character reference comes in three forms, and all of them start with & and end with ;.

Form Syntax Example Result
Named &name; &lt; <
Decimal numeric &#number; &#60; <
Hexadecimal numeric &#xHEX; &#x3C; <

All three examples in the table produce the same character, U+003C. A numeric reference names a Unicode code point, so &#9731; and &#x2603; both produce the snowman, with no need for a name. Named references are easier to read, and only exist for a fixed list defined by the HTML standard. The Wikipedia list of XML and HTML character entity references shows the full set, grouped by Unicode block.

You can verify the equivalence with Python, whose standard library follows the HTML5 rules:

import html
print(html.unescape('&lt; &#60; &#x3C;'))

The output is < < <, three different spellings of one character.

A short history

SGML, the ancestor of HTML, introduced entities as a general text substitution feature. Early HTML needed a way to write accented Latin letters and symbols in a world where documents moved between systems with different character encodings. HTML 2.0 defined a small set of named references for the ISO Latin-1 letters and the markup-significant characters. HTML 3.2 and HTML 4 extended the set with symbols, Greek letters and mathematical operators, which is why names such as &copy;, &eacute;, &alpha; and &rarr; are still familiar.

Two things changed the picture. First, UTF-8 became the dominant encoding of the web, so most characters can now be typed or stored directly in the document, and you no longer need &eacute; just to get an accented letter. Second, HTML5 replaced the old SGML-based parsing model with an algorithm that specifies exactly what every parser must do with every input, including broken input. The HTML5 named reference table grew to more than two thousand entries, many of them for mathematical symbols, and the standard documents behaviour that browsers had converged on over the years, such as accepting some references without a trailing semicolon.

The practical consequence is that entities today have two jobs. The first is cosmetic: writing a character that is awkward to type. The second is essential: making sure that data is never mistaken for markup. This guide spends most of its time on the second job, because that is where security lives.

The five characters that must be escaped

HTML defines a small set of characters that can change how the parser reads a document. Five of them matter in almost every output-encoding discussion.

Character Name Entity Why it matters
& Ampersand &amp; Starts a character reference, so a raw one can swallow the text after it
< Less-than &lt; Starts a tag, so it is the gateway to injecting elements
> Greater-than &gt; Ends a tag; escaping it is good hygiene and avoids edge cases
" Double quote &quot; Ends a double-quoted attribute value
' Apostrophe &#39; or &#x27; Ends a single-quoted attribute value

The ampersand comes first for a reason that we will return to when we discuss double-encoding: if you escape the other characters first and the ampersand last, the ampersands that you just introduced get escaped again. Always replace & before everything else, or do all replacements in a single pass.

Text content versus attribute values

In text content, strictly speaking, only < and & are dangerous. A raw > is legal, and quotes mean nothing between tags. In an attribute value, the quote that delimits the value is dangerous, and & still needs care. Escaping all five characters everywhere is simpler, harmless in text, and correct in attributes, so most libraries do exactly that.

A worked example makes the difference concrete. Suppose a user submits this display name:

Tom "The Wrench" O'Neil <tom@example.com> & friends

Inserted into a paragraph without escaping, the <tom@example.com> would be parsed as an unknown tag and vanish from the page. Inserted into <input value="..."> without escaping, the first double quote would end the attribute, and the rest of the name would become garbage attributes. With escaping, both places display the name correctly:

Tom &quot;The Wrench&quot; O&#39;Neil &lt;tom@example.com&gt; &amp; friends

Why the apostrophe has two common spellings

There is no &apos; in HTML 4. The name came from XML and was added to HTML5, but older browsers did not support it, so many libraries emit the numeric form &#39; or &#x27; to be safe. Python's html.escape produces &#x27;. JavaScript helper functions in the wild produce &#39;. Both are the same character, and either is fine.

Beyond the five: nbsp and other common entities

Some entities have nothing to do with security, and developers meet them daily.

Entity Character Typical use
&nbsp; Non-breaking space (U+00A0) Prevents a line break between two words; keeps a space from collapsing
&copy; Copyright sign Footers
&euro; Euro sign Prices, in files that are not saved as UTF-8
&mdash; and &ndash; Em dash and en dash Typography
&hellip; Horizontal ellipsis Truncated text
&shy; Soft hyphen Marks an allowed hyphenation point
&lsquo; &rsquo; &ldquo; &rdquo; Curly quotes Typography
&times; Multiplication sign Dimensions such as 1920 × 1080

The non-breaking space deserves its own warning

HTML collapses runs of ordinary whitespace into a single space, so authors used &nbsp; to force extra spacing. That habit produces fragile layouts, because a non-breaking space is a different character from a normal space. It does not collapse, it does not break lines, and it is not matched by a plain space in a search or a string comparison. Python shows the difference:

import html
s = html.unescape('a&nbsp;b')
print(repr(s), s == 'a b')

This prints 'a\xa0b' False. A common source of bugs is text copied from a rendered web page into a form, a CSV file or a database. The copy contains U+00A0, and later a comparison, a trim or a regular expression that expects a normal space fails. In JavaScript, String.prototype.trim does remove U+00A0, because the language defines it as white space, but a naive split(" ") does not split on it. Use CSS for spacing (margin, padding, white-space) and reserve &nbsp; for the narrow cases where two words must stay together, such as a number and its unit.

Do you still need named entities for ordinary letters?

No. If your document is saved as UTF-8 and served with a correct Content-Type header or a <meta charset="utf-8"> declaration near the top of the head, you can write é, € and 日本 directly. Entities remain useful when the source file must stay ASCII, when a character is invisible or ambiguous in an editor, such as a soft hyphen or a zero-width space, or when you want to show a reserved character as literal text. Our guide to HTML formatting and semantic markup covers the document-level habits, including the charset declaration, that make this reliable.

How the HTML parser handles references

Understanding the parser removes most of the mystery from entity bugs. The HTML standard describes a tokenizer state machine. When it meets an ampersand in text, or inside an attribute value, it switches to a character reference state and tries to interpret what follows.

Numeric references

For &# followed by digits, or &#x followed by hexadecimal digits, the parser reads the number and converts it to a code point. Several special cases exist, all documented in the standard:

  • A reference to code point zero, such as &#0;, becomes the replacement character U+FFFD.
  • A reference to a code point above U+10FFFF, or to a surrogate half, also becomes U+FFFD.
  • A reference in the range 128 to 159, which in Unicode are control characters, is mapped to the character that the Windows-1252 encoding has at that position. For instance, &#150; yields an en dash. This accommodates old pages written by people who assumed that Windows-1252 was HTML's character set.
  • A missing semicolon after a numeric reference is a parse error, but the parser still recovers and uses the number.

Named references and the longest match

For a named reference, the parser matches the characters after the ampersand against the table of known names, and it picks the longest match. The table contains names that end in a semicolon, and a smaller group of legacy names that are also valid without one. The legacy group exists because early browsers accepted forms like &copy and &amp, and pages in the wild depended on them. The group covers the original Latin-1 and markup names, such as &amp, &lt, &gt, &quot, &copy, &nbsp and &reg.

This produces surprises. Python's html.unescape follows the same table, and you can see the result:

import html
print(html.unescape('&lt;p&gt; &amp;amp; &#60; &#x3C; &copy &copy; &notit; &amp'))

The output is <p> &amp; < < © © ¬it; &. Read it piece by piece:

  • &lt;p&gt; becomes <p>, as expected.
  • &amp;amp; becomes the literal text &amp;. One level of decoding removed one level of encoding.
  • &copy with no semicolon still becomes the copyright sign, because copy is a legacy name.
  • &notit; becomes ¬it;. The longest legacy match is &not, which stands for the not sign, and the remaining it; stays as text. Nothing named &notit; exists.
  • A bare &amp at the end becomes &.

Ambiguous ampersands

An ampersand followed by letters that do not form a known name is called an ambiguous ampersand. In text, the browser leaves it alone and shows the literal characters. Consider a URL written in a page:

<a href="/search?q=cats&copy=1">search</a>

In an attribute value, the standard has a rule that protects this case. If the reference is a legacy name without a semicolon and the next character is an equals sign or an alphanumeric character, the parser does not decode it, so &copy=1 stays as written. In text content no such protection exists, and &copy=1 would show the copyright sign followed by =1. The rule exists only for attributes, since URLs commonly contain ampersands in query strings.

You should not rely on that safety net. The correct way to write the link is to escape the ampersand:

<a href="/search?q=cats&amp;copy=1">search</a>

The browser decodes &amp; to & while parsing the attribute, and the URL it follows is /search?q=cats&copy=1. Validators flag unescaped ampersands in attributes, and a build step that does the escaping for you removes the whole class of bugs.

What the parser does not do

The parser decodes character references in text, in attribute values, and in the <textarea> and <title> elements. It does not decode them inside <script> and <style>, whose content is treated as raw text. It also does not decode them inside comments. This matters later in the article: writing &lt; inside a script does not give you a less-than sign in your JavaScript, it gives you the five characters &lt;. HTML entities are an HTML text feature, not a universal escape mechanism.

Encoding, escaping and sanitising are different things

The vocabulary in this area is used loosely, and the looseness causes real vulnerabilities. Three terms cover most of the ground.

Encoding means converting data to another representation so that it can travel in a given medium. Percent-encoding a URL component, Base64-encoding bytes, and writing a string as UTF-8 are all encodings. HTML entity encoding replaces characters with references.

Escaping means neutralising characters that have special meaning in a particular context so that they are treated as data. Escaping is always relative to a context. A backslash escapes a quote in a JavaScript string, &lt; escapes a less-than sign in HTML text, and %26 escapes an ampersand in a URL query. The same text needs different escaping in different places.

Sanitising means inspecting input that is allowed to contain markup, and removing the parts that are dangerous. A comment system that lets users write bold text and links needs a sanitiser, because it cannot escape all angle brackets without destroying the formatting.

The rule that follows is simple. If the data is meant to be shown as text, escape it for the context where it will appear, at the moment of output. If the data is meant to contain markup, sanitise it with a maintained library, and still apply the other defences discussed below.

Why input filtering is the wrong tool

A tempting idea is to strip or encode "dangerous" characters when data enters the system. It fails for several reasons. The same value will be used in many contexts later, such as HTML, a CSV export, an email, an SQL parameter and a log line, and no single encoding is right for all of them. Encoding at input damages legitimate data: a surname like O'Brien or a company called AT&T should be stored as typed. It also creates double-encoding bugs, because the stored value is already encoded when the output layer encodes it again. Validate input for shape and range, and encode at output.

Context decides the encoding

An escape function that is correct in one place can be useless in another. The OWASP community describes XSS in detail on its Cross Site Scripting page, and a recurring theme there is that output encoding must match the context in which untrusted data appears. The contexts below are the ones that matter most.

Context 1: HTML element content

This is the easy case. Data placed between tags, as in <p>DATA</p>, needs &, < and > escaped, and escaping the quotes as well does no harm. HTML entity encoding is the right tool here.

Context 2: Quoted attribute values

Data placed inside <input value="DATA"> needs the delimiting quote escaped, in addition to & and <. Entity encoding works, provided that the attribute is quoted, and that the template engine escapes whichever quote character you use.

Context 3: Unquoted attribute values

Unquoted attributes are dangerous because the value ends at the first space, and any whitespace character, as well as some punctuation, can end it. The five-character escape does not escape spaces, so it does not keep data inside an unquoted attribute. Python demonstrates it:

import html
print(html.escape('x onmouseover=alert(1)'))

The output is x onmouseover=alert(1), unchanged. If this were placed inside <div class=DATA>, the space would end the class value and start a new attribute. The fix is not a better escape function. The fix is to always quote attribute values. OWASP's guidance suggests encoding all non-alphanumeric characters in unquoted attributes, which is possible, but quoting is simpler and removes the problem.

Context 4: URL attributes

Attributes such as href, src and action contain URLs, and a URL can carry code in its scheme. HTML entity encoding does nothing about the scheme:

import html
print(html.escape('javascript:alert(1)'))

The output is javascript:alert(1), with no character changed, because it contains no special HTML character. Placed in an href, it is still a link that runs script when clicked. For URLs you need two separate steps. First, validate the scheme against an allowlist, typically http, https and mailto, and reject or replace everything else. Second, encode the URL for the attribute, which usually means percent-encoding the parts you built from data and entity-escaping the whole value.

Here is a tested Python helper that does both:

import html
from urllib.parse import urlparse

def safe_url(u):
    p = urlparse(u.strip())
    return u if p.scheme in ('http', 'https', 'mailto', '') else '#'

for u in ['https://example.com/a?b=1&c=2', 'javascript:alert(1)', '/relative', '  JavaScript:alert(1)']:
    print(repr(u), '->', html.escape(safe_url(u)))

The output, as run:

'https://example.com/a?b=1&c=2' -> https://example.com/a?b=1&amp;c=2
'javascript:alert(1)' -> #
'/relative' -> /relative
'  JavaScript:alert(1)' -> #

Notice that the helper strips surrounding whitespace before parsing, and that urlparse lower-cases the scheme, so the mixed-case input is also rejected. Browsers ignore some leading whitespace and control characters in URLs, which is why parsing the same way the browser does matters. An allowlist is always safer than a denylist that looks for the word javascript. Percent-encoding for query values is a separate operation, and in Python urllib.parse.quote does it: quote('a b&c=d/é') returns a%20b%26c%3Dd/%C3%A9.

Context 5: JavaScript contexts

Putting untrusted data inside a <script> block, or inside an inline event handler attribute such as onclick, is where entity encoding breaks down most. Inside a script element, the HTML parser does not decode references, so &lt; is not turned into <, and the data stays in whatever form you wrote it. The JavaScript parser then reads it with its own rules, where a quote or a backslash is what matters, not an ampersand.

The safest pattern is to avoid injecting data into script code altogether. Put the data in a data attribute, escaped for HTML, and read it from the DOM, or embed it as JSON that is encoded for the script context. If you must embed JSON in an inline script, serialise it with a JSON encoder and then additionally escape the characters that can end the script block or confuse the parser. Here is a tested example:

import json

def js_in_html(o):
    return (json.dumps(o)
            .replace('<', '\\u003c')
            .replace('>', '\\u003e')
            .replace('&', '\\u0026'))

print(js_in_html({'n': '</script><b>&'}))

The output is {"n": "</script><b>&"}. A JSON parser reads < as the character <, so the data is unchanged, but the HTML parser never sees a literal </script> sequence that could end the block early. The same idea in JavaScript, tested in Node.js, is JSON.stringify(value).replace(/</g, "\\u003c"), and parsing the result returns the original string. Our JSON formatting guide explains the data format itself, and the JSON minification guide mentions the same script-tag pitfall in its security section.

Context 6: CSS contexts

Data placed inside a <style> block or a style attribute can alter how the page looks, and in older browsers could execute code through legacy features. Current guidance is conservative: do not place untrusted data in CSS at all, other than in a value position, and then only from a strict allowlist, such as a colour that matches a hex pattern. CSS has its own escape syntax, a backslash followed by hexadecimal digits, and HTML entities do not apply inside a style element because the parser treats its content as raw text. Our guide to CSS formatting and architecture discusses structuring styles so that user-controlled values are limited to a small set of custom properties, which is a safer design than concatenating strings into rules.

A summary of contexts

Context Example Correct defence Entity encoding enough?
HTML text <p>DATA</p> Escape &, <, > Yes
Quoted attribute <a title="DATA"> Escape quotes and &, < Yes
Unquoted attribute <a title=DATA> Always quote the attribute No
URL attribute <a href="DATA"> Validate the scheme, then encode No
JavaScript <script>var x = DATA</script> Avoid, or serialise safely as JSON No
CSS <style>p{color:DATA}</style> Allowlist values; avoid No

How XSS actually happens

Cross-site scripting is a vulnerability in which an attacker gets a victim's browser to run script in the context of a trusted site. The script can read page content, act as the user, and send requests with the user's cookies, so the impact can be as bad as a full account takeover. The cause is nearly always the same: data crossed from untrusted to trusted without being encoded for its context. The OWASP page cited above classifies XSS into several types, and the three most common are worth understanding.

Reflected XSS

In reflected XSS, the server takes a value from the request, such as a search term in a query string, and writes it into the response. The attack is delivered through a crafted link, and the victim has to follow it. Here is a deliberately broken server-side template, followed by what escaping does to it:

<h1>Results for: DATA</h1>

If DATA is the text <b>hi</b>, an unescaped server sends markup, and the browser renders bold text. That tiny example is harmless, and it proves the point: the input was interpreted as markup. An attacker would substitute a script-carrying element for the bold tag. With correct escaping, the browser receives &lt;b&gt;hi&lt;/b&gt; and displays the literal characters, with no element created.

Stored XSS

In stored XSS, the malicious input is saved, in a database, a log viewer or a profile field, and served to everyone who views it. It is more dangerous than reflected XSS because no crafted link is needed: a comment, a username or a support ticket can carry the payload to an administrator who opens it. Stored XSS often hides in secondary screens, such as an admin panel that lists user-submitted values and trusts them because "they came from our own database". The database is not a trust boundary. Encode at output regardless of where the data came from.

DOM-based XSS

In DOM-based XSS, the server may be perfectly safe. The vulnerability is in client-side code that reads data from a source the attacker controls, such as location.hash, location.search or document.referrer, and writes it into a dangerous sink, such as innerHTML. The flow never has to touch the server, so server-side output encoding cannot catch it.

A short illustration of the pattern, with the safe fix beside it:

// Risky: the value is parsed as HTML
el.innerHTML = decodeURIComponent(location.hash.slice(1));

// Safe: the value is treated as text
el.textContent = decodeURIComponent(location.hash.slice(1));

Both lines read the same attacker-influenced source. The first one hands the string to the HTML parser. The second one creates a text node, so every special character is displayed literally, and nothing is interpreted. This single change eliminates a large share of DOM XSS bugs.

What the neutralised version looks like

The point of escaping is that the dangerous characters survive as harmless text. Using the hand-rolled function from the next section, the following input is rendered inert:

input : <script>alert(1)</script>
output: &lt;script&gt;alert(1)&lt;/script&gt;

The browser displays the original characters on screen, and creates no script element. That visible, boring result is the goal of output encoding.

Safe DOM APIs

On the client side, the most important decision is which API receives the data. Some APIs treat strings as text. Others parse them as HTML.

API Treats the string as Safe for untrusted text?
textContent Text Yes
innerText Text (layout-aware) Yes
setAttribute("title", s) Attribute text Yes, for non-URL, non-event attributes
document.createTextNode(s) Text Yes
innerHTML HTML No, unless sanitised
outerHTML HTML No, unless sanitised
insertAdjacentHTML HTML No, unless sanitised
document.write HTML No; avoid entirely
el.href = s or el.src = s URL Only after scheme validation

textContent versus innerHTML

textContent replaces all children with a single text node. The browser never parses the string, so there is nothing to escape. It is also faster than innerHTML for plain text, since no parsing happens. Use it by default. Reserve innerHTML for markup that you wrote yourself or that came out of a sanitiser.

A common bug is building a string of HTML with template literals and handing it to innerHTML:

// Risky: user.name is interpolated into markup
list.innerHTML += `<li>${user.name}</li>`;

The safe alternatives are to create elements and set textContent, or to run each interpolated value through an escape function before concatenation. The first is more reliable:

const li = document.createElement("li");
li.textContent = user.name;
list.append(li);

Escaping text in the browser

If you need the escaped string, for instance to build an HTML email on the client, you can use a throwaway element to turn text into entities, though a plain function is clearer and does not depend on the DOM. A well-known trick sets textContent on a div and reads innerHTML. That reads back escaped &, < and >, but it does not escape quote characters, so it is unsuitable for attribute contexts. A dedicated function that covers all five characters is a better choice.

Writing an escape function and verifying it

An escape function is a good exercise because it is short, and because the failure modes are instructive. Here is a complete JavaScript implementation that processes the string in one pass, which avoids the ordering problem for the ampersand:

const MAP = { "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" };
const esc = (s) => String(s).replace(/[&<>"']/g, (c) => MAP[c]);

for (const t of ['<script>alert(1)</script>', 'a & b', '"q" \'s\'', '&amp;', '<>&"\'', 'é 日本 😀']) {
  console.log(esc(t));
}
console.log(esc(esc('a & b')));

I ran it in Node.js, and the output was:

&lt;script&gt;alert(1)&lt;/script&gt;
a &amp; b
&quot;q&quot; &#39;s&#39;
&amp;amp;
&lt;&gt;&amp;&quot;&#39;
é 日本 😀
a &amp;amp; b

Three observations. First, &amp; in the input becomes &amp;amp;, because the function cannot know whether the ampersand is already part of a reference, and it must assume that all input is plain text. Second, non-ASCII characters pass through untouched, since they are harmless in a UTF-8 document. Third, running the function twice on a & b gives a &amp;amp; b, the double-encoding bug in miniature.

Verifying against a standard library

A hand-rolled function should be checked against something trusted. Python's html.escape is a good reference, since its behaviour is documented. The Python version below uses the same replacement map, with the apostrophe spelled the way Python spells it, and compares the two over a list of test strings:

import html

MAP = {'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#x27;'}

def esc(s):
    return ''.join(MAP.get(c, c) for c in s)

tests = ['', 'plain', '<script>alert(1)</script>', 'a & b',
         '"q" \'s\'', '&amp;', 'é ü 日本 😀', '<>&"\'']
print(all(esc(t) == html.escape(t) for t in tests))

The output was True. The function walks the string character by character and substitutes from the map, so there is no ordering problem at all. Note that html.escape has a quote parameter that defaults to True. Passing quote=False leaves the quote characters alone, which is correct only for text content, not for attributes. The difference is visible:

import html
s = '<a href="x">Tom & Jerry\'s</a>'
print(html.escape(s))
print(html.escape(s, quote=False))

The output was:

&lt;a href=&quot;x&quot;&gt;Tom &amp; Jerry&#x27;s&lt;/a&gt;
&lt;a href="x"&gt;Tom &amp; Jerry's&lt;/a&gt;

Decoding is the reverse, with caveats

Decoding is the opposite operation, and a hand-rolled version only handles the five characters that your own escape function produced:

const UNMAP = { "&amp;": "&", "&lt;": "<", "&gt;": ">", "&quot;": '"', "&#39;": "'" };
const unesc = (s) => s.replace(/&(amp|lt|gt|quot|#39);/g, (m) => UNMAP[m]);

console.log(unesc(esc('<b>"x" & \'y\'</b>')));
console.log(unesc('&amp;lt;'));

The output was <b>"x" & 'y'</b> for the round trip, and &lt; for the second line. The round trip returns the original, and the second line shows that decoding &amp;lt; once removes only one layer. A general decoder must handle all named references, numeric references and the legacy rules described earlier, so use a library, such as Python's html.unescape, rather than a regular expression, for arbitrary input.

An important security point: decoding is not a safe operation to apply to untrusted data and then insert into a page. Decoding &lt;script&gt; produces markup characters. Use decoding for display, comparison and processing, and encode again at the output boundary.

Unicode and code points

Numeric references name code points, not UTF-16 code units. In JavaScript a character outside the Basic Multilingual Plane, such as an emoji, is two code units. The emoji U+1F600 has length 2 and codePointAt(0) of 1f600, and String.fromCodePoint(0x1F600) builds it. That means &#x1F600; is a single reference for a single character, and a function that handles strings by indexing code units may split the character in half. Use for...of, spread syntax or codePointAt when you process characters one at a time.

Real-world use cases

Rendering user-generated text. Comments, usernames, product reviews and chat messages are all untrusted text that must be escaped for the context where it appears. Most frameworks do this for you, as described below.

Showing code on a page. Tutorials and documentation display HTML source inside <pre><code> blocks. Every < and & in the snippet must be escaped, or the browser will render the example instead of showing it. A tool that encodes a pasted snippet saves a lot of manual work.

Generating HTML emails and templates. Email clients are less forgiving than browsers, and template engines for email often lack auto-escaping. Encoding dynamic values by hand is common here.

Feeds and XML. XML defines only five predefined entities, the same five discussed earlier, with &apos; included. RSS and Atom documents with text from a database must escape these characters or the feed becomes malformed XML. Our XML formatting and structure guide covers the rules, and the SVG formatting guide is relevant because SVG is XML embedded in HTML.

Cleaning scraped or imported data. Text pulled from web pages, old databases or feeds often contains references such as &amp;, &#8217; and &nbsp;. Decoding them produces clean text for search, analysis or display in a non-HTML context.

Debugging double-encoded content. When a page shows entity text where characters should be, decoding the string step by step reveals how many times it was encoded and where in the pipeline the extra pass happened.

Embedding data in attributes. Storing a small JSON string in a data-* attribute is a handy pattern, and works well when the JSON is entity-escaped. The browser decodes the references when it parses the attribute, so dataset returns the original JSON.

Frameworks escape for you, until they do not

Modern frameworks escape interpolated values by default, which prevents most accidents. Knowing exactly what they do, and where the escape hatches are, is what separates safe code from lucky code.

What auto-escaping covers

React escapes values placed inside JSX expressions, such as <p>{name}</p>, before inserting them into the DOM. Vue escapes mustache interpolation, such as {{ name }}, and the v-bind attribute syntax. Angular treats bound values as untrusted and sanitises them according to context. Server-side template engines such as Jinja2 (with autoescape enabled), Django templates, Handlebars with double braces, and many others escape HTML special characters in variable output. The details differ, but the principle is the same: the default path treats data as text.

The escape hatches

Every framework offers a way to switch the protection off, and it is intentionally named to make you think.

Framework Escape hatch What it does
React dangerouslySetInnerHTML Sets raw HTML on an element
Vue v-html Renders a string as HTML
Angular bypassSecurityTrustHtml and similar Marks a value as already safe
Jinja2 The safe filter, Markup Skips escaping for that value
Django The safe filter, mark_safe Skips escaping for that value
Handlebars Triple braces {{{ x }}} Outputs unescaped

Any use of these features is a statement that the value is trusted. If the value comes from users, a database field or any third party, it should pass through a sanitiser first. A code review habit that works well is to search the codebase for each of these names and demand an explanation next to every hit.

Where auto-escaping stops

Auto-escaping is context-aware only to a point. Frameworks typically protect text and quoted attributes. They do not guarantee that an href value is a safe URL, since a javascript: URL is plain text from an escaping point of view. They also cannot protect values that you concatenate into a script block or an event handler string. In React, a value like <a href={userUrl}> needs scheme validation by you. Vue and Angular have more built-in handling for URLs, but you should not assume any framework fixes this for you without reading its documentation.

Server-side rendering and client-side hydration add one more detail. Many frameworks embed initial state as JSON in a script tag. That JSON must be serialised with the script-context escaping described earlier, and well-maintained frameworks handle it, but homemade setups often do not.

Sanitising rich HTML

Sometimes escaping is not an option because users are supposed to supply markup, such as a rich text editor in a CMS, a Markdown preview, or an HTML email viewer. The tool for this job is a sanitiser: a library that parses the HTML into a tree, removes everything that is not on an allowlist, and serialises the safe result.

DOMPurify is a widely used sanitiser for browsers and Node.js, and is the sort of library you should reach for here. A typical use looks like this:

import DOMPurify from "dompurify";

const clean = DOMPurify.sanitize(dirtyHtml);
container.innerHTML = clean;

Several rules apply to sanitisers, whichever one you pick.

  1. Use a maintained library, and keep it updated. HTML parsing has enough odd corners that hand-written regular expressions are unsafe. Sanitisers are updated as browsers change, so an old copy can fall behind.
  2. Sanitise as late as possible, ideally right before insertion. If you store sanitised HTML and your allowlist later changes, old data remains under the old rules.
  3. Do not modify the output after sanitising. Concatenating strings or running a transform on the cleaned HTML can reintroduce a problem the sanitiser had removed.
  4. Use the narrowest allowlist that does the job. If users only need bold, italics, links and lists, allow only those tags and attributes.
  5. Validate link schemes. Make sure the sanitiser configuration blocks dangerous URL schemes on href and src.
  6. Prefer a safer format where possible. A restricted Markdown dialect, rendered by a library and then sanitised, gives users less room to cause trouble than free HTML.

Browsers have been moving toward a built-in sanitising API, and you may see it referred to as the HTML Sanitizer API. Support varies and the specification was still evolving at the time of writing, so check current MDN documentation before depending on it. A library remains the dependable choice for production code today.

Content Security Policy as defence in depth

Escaping and sanitising prevent injected markup. A Content Security Policy (CSP) limits the damage if one slips through. It is an HTTP response header that tells the browser which sources of script, style, images and other resources the page may use. A policy that does not allow inline scripts makes the classic injection of a script element ineffective, even if the markup reaches the page.

A strong starting point looks like this, shown as a header value with line breaks for readability:

Content-Security-Policy:
  default-src 'self';
  script-src 'self' 'nonce-RANDOM';
  object-src 'none';
  base-uri 'none';
  frame-ancestors 'self'

Here 'nonce-RANDOM' stands for a fresh unpredictable value generated for each response, and added to the nonce attribute of each script tag that you trust. Inline scripts without the matching nonce are blocked. The base-uri and object-src directives close routes that attackers sometimes use to bring in outside code.

A few points keep expectations realistic:

  • CSP is a second line of defence. It does not replace output encoding, and it does not stop an attacker from altering the page's visible content with injected HTML that does not need script, such as a fake login form.
  • Adopt it gradually. The Content-Security-Policy-Report-Only header lets you collect violation reports without breaking anything, and you can tighten the policy once the reports are clean.
  • Avoid 'unsafe-inline' and 'unsafe-eval' for scripts. They remove most of the benefit.
  • Test it. Open the browser console, which reports every blocked resource and names the directive responsible.

The MDN documentation on CSP lists all directives and has deployment advice. Complementary cookie attributes, such as HttpOnly, limit what a script can read even when injection succeeds, so they belong in the same layered strategy.

Double-encoding and double-decoding

Double-encoding is the single most common entity bug in production, and it has an easy signature. Text that should read Tom & Jerry shows as Tom &amp; Jerry, and an apostrophe becomes &#39; on screen.

The mechanism is that two layers each escape the data once. Python reproduces it in two lines:

import html
print(html.escape(html.escape('a & b')))

The output is a &amp;amp; b. The first pass produced a &amp; b, and the second turned the ampersand of that reference into &amp;. When the browser decodes it once, it displays a &amp; b.

Typical sources of a second pass:

  • The data was escaped when saved and escaped again when displayed.
  • The API returns HTML-escaped strings, and the front end escapes them again when rendering.
  • A template engine escapes automatically, and a developer also called an escape function by hand.
  • A value was escaped in the controller, passed to a view, and escaped by the view.

The reverse bug, double-decoding, is more dangerous. If one layer decodes and a later layer inserts the result without encoding, the decoded markup becomes live. For example, a filter that decodes entities before checking for forbidden tags, followed by a step that decodes again before output, can hand a decoded < to the browser. The principle that prevents both bugs is simple: store raw text, encode exactly once, at the point of output, and treat every decode as producing text that needs a fresh encode before it goes into HTML.

Step-by-step: encode and decode entities with DevUtilX

When you need a quick conversion without writing code, use the DevUtilX tools. Both run in your browser, so what you paste is processed locally and never uploaded.

Encoding text

  1. Open the HTML Entity Encoder.
  2. Paste the text or code snippet that you want to display literally, for example a block of HTML for a tutorial.
  3. Choose the options you need. For text that will go into an attribute, make sure that quotes are encoded as well.
  4. Run the encoder and review the output. You should see &lt;, &gt; and &amp; where the original had markup characters.
  5. Copy the result into your page, a <pre><code> block or a template.

Decoding entities

  1. Open the HTML Entity Decoder.
  2. Paste text that contains references such as &amp;, &#8217; or &nbsp;.
  3. Run the decoder and read the plain text it produces.
  4. If the output still contains references, the input was encoded more than once. Run the decoder again and count the passes. That count tells you how many layers of encoding your pipeline applied.
  5. Copy the cleaned text.

Because the tools work in the browser, they are suitable for content that you would not want to send to a third party, such as internal templates or customer text. As with any online tool, avoid pasting secrets and credentials. The decoder is helpful when you inspect a suspicious string, since it shows what the browser would eventually see, though the safest habit is to treat the decoded result as text and never paste it into a live page.

Code examples in three languages

The same operations exist in every language, with small differences in which characters are covered by default.

JavaScript

const MAP = { "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" };
const escapeHtml = (s) => String(s).replace(/[&<>"']/g, (c) => MAP[c]);

console.log(escapeHtml(null), escapeHtml(undefined), escapeHtml(42));

The output was null undefined 42. The String(s) call matters: without it, a number or null would throw or behave oddly, but it also means that a missing value turns into the literal word "null". Decide explicitly how your code should treat missing values before they reach the escape function, rather than letting the word appear in the page.

Python

import html

print(html.escape('<a href="x">Tom & Jerry\'s</a>'))
print(repr(html.unescape('&nbsp;')))
print(html.unescape('&amp;lt;'))
print(html.unescape(html.unescape('&amp;lt;b&amp;gt;')))

The output was:

&lt;a href=&quot;x&quot;&gt;Tom &amp; Jerry&#x27;s&lt;/a&gt;
'\xa0'
&lt;
<b>

Reading it: html.escape encodes all five characters by default. html.unescape('&nbsp;') returns the non-breaking space character, shown as \xa0 by repr. A single unescape of &amp;lt; returns &lt;, and a second call returns <. The last line shows how repeated decoding can turn harmless text into markup characters, which is why you should never decode in a loop until the text stops changing and then insert the result into a page.

Bash with Python as a helper

For a quick command-line conversion, the Python standard library is available on most systems:

python3 -c 'import html,sys; print(html.escape(sys.stdin.read(), quote=True), end="")' < snippet.html

This reads a file, escapes it, and prints the result, which is handy for turning a snippet into text suitable for a documentation page. Swap escape for unescape to go the other way.

Performance, security and accessibility considerations

Performance

Escaping is cheap. A single regular-expression replace over a typical paragraph is far faster than the network request that delivered it, and template engines compile escape calls into efficient code. Two practical points are worth knowing. First, escape once, because repeated passes over large strings add up and also cause the bugs described above. Second, textContent is generally faster than building an HTML string and parsing it with innerHTML, so the safe choice is usually also the quicker one for plain text. As with any performance claim, measure on your own pages before optimising.

Security

The security story has a handful of rules that cover most real cases:

  • Encode at output, for the exact context.
  • Quote every attribute.
  • Validate URL schemes with an allowlist.
  • Keep untrusted data out of script and style blocks.
  • Sanitise any HTML that users may supply, with a maintained library.
  • Add a Content Security Policy as a backstop.
  • Treat data from your own database, from third-party APIs and from query strings as equally untrusted.

Do not try to detect attacks by searching for known payload strings. Pattern-based filters are brittle, and they give a false sense of safety, while consistent output encoding removes the vulnerability at its source without needing to recognise any particular input. Web application firewalls can add useful monitoring, but they are not a substitute for correct encoding in your own code.

Accessibility

Entities affect how people read and hear your content. A few considerations:

  • Screen readers read the decoded character. &copy; is read as copyright, and &nbsp; is read as a space. Using entities as decorations, such as &bull; or &rarr;, may be announced as "bullet" or "rightwards arrow", which can be noisy. Hide purely decorative symbols with CSS generated content or aria-hidden="true" on a wrapper.
  • Do not use &nbsp; for layout. Long runs of non-breaking spaces are announced awkwardly by some assistive technology and break responsive reflow.
  • Soft hyphens and zero-width characters can change pronunciation or search matching. Use them sparingly and test with a screen reader.
  • Language and encoding. A correct charset declaration and lang attribute let assistive technology choose the right voice and handle non-Latin text without needing numeric references.
  • Math and symbols. If you use entities for mathematical notation, consider MathML or properly labelled text so that the meaning, not only the glyph, reaches the user.

Common mistakes

  • Escaping on input and again on output. This is the usual cause of &amp;amp; on the page. Store raw text and encode at output.
  • Assuming HTML escaping protects URLs. The text javascript: has no special HTML character, so it passes through unchanged. Validate the scheme.
  • Leaving attributes unquoted. A space ends the value, and escaping does not add quotes for you.
  • Putting untrusted data inside script blocks or event handler attributes. HTML entity encoding is the wrong tool in that context.
  • Using innerHTML for plain text. Use textContent.
  • Replacing characters in the wrong order. If you replace < before &, the new ampersands get escaped again. Use a single-pass function.
  • Using html.escape(s, quote=False) for attribute values. Quotes stay raw, so the attribute can be closed early.
  • Decoding untrusted text and then inserting it. Decoding creates markup characters. Encode again at the boundary.
  • Relying on regular expressions to strip tags. A pattern that removes <script> misses many other ways to reach the same outcome. Use a real sanitiser.
  • Mistaking &nbsp; for a regular space. String comparisons and parsers see U+00A0, which is a different character.
  • Trusting data because it comes from your own database. Stored XSS depends on exactly that assumption.
  • Skipping the semicolon. Some references work without it for historical reasons, but others do not, and the results depend on what follows. Always write the semicolon.

Troubleshooting: symptom, cause, fix

Symptom: the page shows &amp;amp; or &amp; where & should be

Cause. The text was encoded twice, or it was encoded once and then displayed through an API that does not decode. A common version is escaped data stored in the database and then escaped again by the template engine.
Fix. Find the layers that touch the string. Decode the value in the HTML Entity Decoder and count how many passes are needed to reach the plain text. Then remove all but one encoding step, the one at output. Migrate any stored data that was saved in encoded form.

Symptom: &lt;b&gt; appears on the page, and I wanted bold text

Cause. The markup was escaped on purpose or by default, so it is displayed as text. This is the intended behaviour for untrusted content.
Fix. If the markup is trusted, such as content from your own CMS, render it through the framework's raw HTML mechanism. If it is user-supplied, pass it through a sanitiser first and then render it as HTML. Do not simply switch escaping off.

Symptom: a literal < or & breaks the layout or swallows text

Cause. The value was inserted without escaping, so the parser treated it as markup or as the start of a character reference.
Fix. Escape & and < at output, and confirm that the template engine's auto-escaping is on for this template. In plain strings, use the escape function from your language's standard library.

Symptom: an attribute value gets cut off at a quote

Cause. The data contains a quote character that matches the attribute delimiter, and it was not escaped. With Python, a call to html.escape(s, quote=False) is a typical cause.
Fix. Escape quotes as well as &, < and >, and always use quotes around attribute values.

Symptom: string comparison fails on text that looks identical

Cause. One of the strings contains U+00A0 from a decoded &nbsp;, or another invisible character such as a zero-width space or soft hyphen.
Fix. Print the string with repr in Python or JSON.stringify in JavaScript, which show escapes for these characters. Normalise with a replacement that maps U+00A0 to a plain space, then compare again.

Symptom: &copy=1 in a URL changed or was left alone

Cause. The attribute rule for legacy references applies only inside attribute values. In text content, &copy decodes even when the next character is an equals sign, but in an attribute it does not.
Fix. Write &amp; for every ampersand in a URL placed in HTML, including query strings, and the ambiguity disappears.

Symptom: characters show as é or ’ instead of the intended letters

Cause. This is not an entity problem, it is a character encoding mismatch. UTF-8 bytes were read as Windows-1252 or Latin-1.
Fix. Serve the document as UTF-8, declare it in the HTTP header and in a meta tag, and make sure that the files and database connections use UTF-8 as well. Entities hide the problem for some characters, but fixing the encoding is the real remedy.

Symptom: a numeric reference shows a replacement character or a different glyph than expected

Cause. The number was zero, out of range, a surrogate, or in the 128 to 159 range, where the parser applies special mappings. Another possible cause is that the number was written in decimal but meant as hexadecimal, or the reverse.
Fix. Check the code point in a Unicode table, write hexadecimal references with &#x, and confirm that the font in use has the glyph.

Symptom: a script-tag JSON blob breaks the page

Cause. A string value contains </script> or an HTML comment opener, which ends the script block early.
Fix. Serialise with a JSON encoder, then escape <, > and & as Unicode escapes, as shown in the script-context example above.

Symptom: sanitised HTML is stripped more than expected

Cause. The allowlist does not include the tags or attributes your content uses.
Fix. Review the sanitiser's configuration and permit only what you need. Add tags one at a time, and test with representative content.

Best practices and checklist

  • Store raw text, encode at output. Never keep HTML-escaped copies of user text as the canonical value.
  • Encode for the exact context. HTML text, attribute, URL, script and CSS each need different handling.
  • Use the framework's default escaping, and search for every escape hatch during code review.
  • Quote all attribute values, with double quotes by convention.
  • Allowlist URL schemes for href, src and action.
  • Prefer textContent to innerHTML, and createElement to string concatenation.
  • Sanitise user-supplied HTML with a maintained library, as close to the point of insertion as you can.
  • Send a Content Security Policy, starting in report-only mode, and avoid 'unsafe-inline'.
  • Declare UTF-8 and write characters directly when you can.
  • Always write the semicolon in character references.
  • Use &amp; in URLs inside HTML attributes.
  • Test with awkward input: quotes, ampersands, angle brackets, non-ASCII text and emoji.
  • Compare differences with a diff tool when debugging encoding changes. Our guide to comparing files and code diffs explains how to spot a one-character change in long output.

A short review checklist for a pull request that touches output:

  1. Where does each dynamic value come from, and is it trusted?
  2. In which context does it appear?
  3. Which function encodes it for that context, and is it called exactly once?
  4. Does any code use innerHTML, v-html, dangerouslySetInnerHTML, |safe or triple braces?
  5. Are URLs validated against an allowlist of schemes?
  6. Is there a CSP, and does it block inline script?
  7. Are there tests with hostile and awkward strings?

Comparison: ways to handle untrusted content in HTML

Approach Best for Strength Limitation
HTML entity encoding Text and quoted attributes Simple, fast, complete for those contexts Does nothing for URLs, script or CSS contexts
textContent and createTextNode Client-side text No parsing at all Cannot produce markup
Framework auto-escaping Everyday templates Safe by default Escape hatches and URL values still need care
URL scheme allowlist plus percent-encoding Links and redirects Blocks script URLs Must be paired with entity encoding
JSON with script-safe escaping Passing data to scripts Keeps data out of code Needs the extra escapes inside inline scripts
Sanitiser (DOMPurify and similar) Rich HTML from users Keeps safe markup Needs configuration and updates; not for plain text
Content Security Policy Defence in depth Limits impact of mistakes Does not replace encoding; takes effort to deploy
Safer markup language (restricted Markdown) User formatting Smaller attack surface Less expressive than HTML

No single row is a complete solution. Real applications combine several: framework escaping for everyday output, a sanitiser for the few places where users supply HTML, an allowlist for URLs, and a CSP as the safety net.

FAQ

What is the difference between an HTML entity and a character reference?
In strict terms, an entity is a named unit of replacement text, and a character reference is the use of one in a document. In everyday speech, and in this article, "HTML entity" means any of the named, decimal or hexadecimal forms.

Which characters must be escaped in HTML?
The ampersand and the less-than sign are essential in text. Inside attribute values, the quote character that delimits the value must be escaped too. Escaping all five of &, <, >, " and ' everywhere is the simple and safe habit.

Is &apos; valid in HTML?
It is defined in HTML5 and XML, but it was not part of HTML 4, so some old software does not recognise it. The numeric forms &#39; and &#x27; work everywhere.

Do I need &nbsp; to add spacing?
No. Use CSS margins and padding for layout. Use &nbsp; only to keep two specific words, such as a value and its unit, on the same line.

Does the browser require the trailing semicolon?
For a group of legacy names, such as &amp and &copy, the parser accepts the reference without a semicolon, and reports a parse error. Other names require it, and the result depends on the characters that follow. Always include the semicolon.

Is entity encoding enough to prevent XSS?
It is enough in HTML text and in quoted attribute values. It is not enough in URL attributes, script blocks, event handler attributes, unquoted attributes or CSS, where you need a different technique for each context.

What is the difference between textContent and innerHTML?
textContent sets plain text, so special characters display literally. innerHTML parses the string as HTML and builds elements from it, which is dangerous with untrusted input.

Why does my page show &amp;amp;?
The string was encoded twice. Find the two layers that escape it, and keep only the one at output.

Should I escape data before saving it to the database?
No. Store the raw value, and encode for the right context each time you output it. A value that is escaped for HTML is wrong for CSV, JSON, SQL or email.

Is html.escape in Python safe for attributes?
With the default quote=True, it escapes both quote characters, so it is safe in a quoted attribute. It does not make unquoted attributes, URLs or script contexts safe.

Does React protect me from XSS?
React escapes values in JSX expressions by default. It does not protect dangerouslySetInnerHTML, it does not validate URL schemes in href, and it cannot protect code you inject by other means. Treat those as places that require your own checks.

When should I use a sanitiser like DOMPurify?
When users are allowed to submit HTML or Markdown that becomes HTML, and you need to keep some formatting. For plain text, escaping or textContent is the right and simpler tool.

Can a Content Security Policy replace output encoding?
No. CSP reduces the damage when something is missed, mainly by blocking inline and foreign scripts. Injected markup can still change what the page shows, so encoding stays essential.

Are numeric references safer than named ones?
Neither is safer in itself. Both are decoded by the parser into the same characters. Numeric references are a good choice for characters that have no name, or when an ASCII-only file requires them.

Are entities decoded inside <script> and <style>?
No. Their content is raw text, so &lt; stays as five characters, which is why HTML encoding does not help in those contexts.

Is it safe to use an online entity tool for private text?
It is safe only if the tool runs in your browser. The DevUtilX tools process your input locally. Even so, do not paste passwords, tokens or personal data into any tool.

Conclusion

HTML entities exist to keep data and markup separate. The five special characters, &, <, >, " and ', are the ones to escape in text and quoted attributes. Beyond that, the rule is to encode once, at output, for the exact context, and to remember that URLs, scripts and CSS need their own treatment. Use textContent when you can, keep framework auto-escaping on, sanitise real HTML with a maintained library, and add a Content Security Policy as a backstop.

When you need a quick conversion, try the HTML Entity Encoder to turn a snippet into safe text, and the HTML Entity Decoder to read what an encoded string really says. To keep going, read our guides on HTML formatting and semantic markup and XML formatting and structure, and see how the script-context escaping connects to JSON in the JSON formatting guide.

Further reading

Try the tools

Further reading

Related articles