Published
Escaping is not something you do to a string once and then stop thinking about. The correct transformation depends entirely on where the value is going to land, and a value escaped for one context is often still dangerous, or merely broken, in another. This guide sets out the contexts, the rules for each, the entity forms available, and the reason that filtering out dangerous-looking input has never been a working defence.
Escaping is a property of the destination, not the data
An HTML document is not one language. A browser parses it with several: the HTML tokenizer for markup, a URL parser for anything in an href or src, a JavaScript parser inside a script element or an event handler attribute, and a CSS parser inside a style element or attribute. Each of those has its own idea of which characters are structural, and each stops parsing the moment it hands off to the next.
That is why a single escape function cannot be correct. Replacing angle brackets with entities is exactly right for element text and does nothing whatsoever inside a script block, where the dangerous characters are quotes and backslashes and where an entity is just seven literal characters in a string. Percent-encoding is right for a URL and wrong for text. The question is never "is this string escaped" but "is this string escaped for the place it is about to be inserted".
The practical consequence is that escaping belongs at the point of output, not at the point of input. A value stored escaped is a value you cannot search, cannot compare, cannot re-render in a different context, and will eventually double-escape when someone adds a second layer. Store the original; encode when you emit.
The contexts and their rules
There are five contexts worth naming, plus one that deserves to be treated as forbidden. Element text is the simplest: the tokenizer is looking for a less-than sign to start a tag and an ampersand to start a character reference, so those two must be encoded, and encoding the greater-than sign as well costs nothing and avoids edge cases around comment and CDATA-like sequences.
Attribute values are text plus a delimiter. If the value is quoted — and it always should be — the matching quote character must be encoded, because a raw quote closes the attribute and everything after it is parsed as new attributes. An unquoted attribute value is terminated by a space, a tab, a newline, a form feed, a greater-than sign and several other characters, which is why unquoted attributes should simply never hold interpolated data.
The JavaScript and CSS contexts are where hand-rolling stops being viable. Inside a script element the HTML tokenizer is still watching for the character sequence that ends the element, so a value containing a closing script tag terminates the block no matter how carefully the JavaScript string itself was escaped — which is why serialising to JSON is necessary but not sufficient, and the less-than sign must additionally be emitted as a unicode escape. Inside CSS, the safest posture is not escaping at all but an allowlist: accept a colour or a length you have validated, and never interpolate arbitrary text into a property value or, worse, into a url() reference.
The rightmost column is the transformation to apply at the moment the value is written into the document.
| Context | Example position | Encoding required |
|---|---|---|
| Element text | <p>VALUE</p> | Entity-encode & < > |
| Quoted attribute | <a title="VALUE"> | Entity-encode & < > " '; always keep the quotes |
| Unquoted attribute | <a title=VALUE> | Do not interpolate here at all |
| URL in an attribute | <a href="/s?q=VALUE"> | Percent-encode first, then entity-encode the result |
| Script data | <script>var x = VALUE;</script> | JSON-serialise, then escape < as \u003c |
| CSS value | <style>a { color: VALUE }</style> | Validate against an allowlist; do not interpolate free text |
| Event handler attribute | <a onclick="VALUE"> | Two parsers at once; treat as forbidden |
Named versus numeric entities
A character reference can name a character or number it. HTML5 defines more than two thousand named references, from the five everyone knows to mathematical and typographic symbols. Numeric references identify a character by its Unicode code point, in decimal as — or in hexadecimal as —, and they work for every character without requiring the consumer to know a name.
For the five characters that matter structurally, the named forms are the ones to use, with one exception worth remembering: the apostrophe. ' is defined in XML and in HTML5, but it was absent from HTML 4, so it can fail to resolve in older parsers and in some XML-to-HTML pipelines. The numeric ' has always worked everywhere and is the portable choice when you are escaping for a single-quoted attribute.
Beyond the structural five, named entities are a readability convenience rather than a correctness requirement. In a UTF-8 document there is no need to write — when you can write the em dash itself; the entity matters when the file encoding is uncertain, when the character is invisible and you want it to be obvious in source, or when a downstream system is known to mangle non-ASCII. That last case is the honest reason survives: a non-breaking space and an ordinary space look identical in an editor, and writing the entity makes the intent reviewable.
Two mechanical details cause most entity bugs. The semicolon is part of the reference and should never be omitted, even though HTML's parser tolerates some references without one for legacy reasons — that tolerance differs between element text and attribute values and is not worth relying on. And when escaping by hand, the ampersand must be replaced first; replacing it after the others turns an already-emitted < into &lt; and the reader sees the escape rather than the character.
| Character | Named | Decimal | Hexadecimal |
|---|---|---|---|
| ampersand | & | & | & |
| less-than | < | < | < |
| greater-than | > | > | > |
| double quote | " | " | " |
| apostrophe | ' (not in HTML 4) | ' | ' |
| non-breaking space | |   |   |
| soft hyphen | ­ | ­ | ­ |
| em dash | — | — | — |
Attributes: the quoting is part of the escape
An attribute value and the quotes around it are a single mechanism. If you know the value is inside double quotes, encoding the double quote is sufficient to keep the value contained; if the template might be edited to use single quotes, or a code generator emits either, then both quote characters have to be encoded. Encoding both, always, costs a few bytes and removes a class of bug that only appears when someone reformats a template.
Unquoted attributes are worse than they look. The HTML specification terminates an unquoted value at whitespace, but browsers also accept a range of other characters as separators, and a value that starts with a character the parser treats specially can introduce an entirely new attribute. A payload does not need an angle bracket or a quote to escape an unquoted attribute; a space and the text onmouseover=... is enough. There is no safe escaping strategy for this context, which is why the rule is to quote every attribute, without exception.
Some attributes are dangerous regardless of escaping, because the value itself is code or a resource reference. Any attribute whose name begins with on- is an event handler and holds JavaScript. The href, src, action, formaction and data attributes take URLs, and a URL beginning with javascript: executes on click no matter how perfectly the string was entity-encoded. Escaping is the wrong tool there; validation of the scheme is the right one.
Safe, correctly escaped text in an attribute:
<a title="Tom & Jerry <the cartoon>">link</a>
Entity-encoded and still executable, because the scheme is the problem:
<a href="javascript:alert(1)">link</a>
Unquoted attribute, no angle bracket or quote needed:
value: x onmouseover=alert(1)
output: <a title=x onmouseover=alert(1)>
The fix is not a longer escape list:
- quote every attribute value
- allow only http, https and mailto in URL attributes
- never build an on* attribute from data; attach the listener in codeURLs inside HTML need two layers, in order
A link built from user data sits in two grammars at once. The value has to be percent-encoded so that it stays inside its URL component, and the resulting URL has to be entity-encoded so that it stays inside its HTML attribute. Skipping either layer produces a defect; applying them in the wrong order produces a different one.
The order is percent-encoding first, entity-encoding second. Percent-encode the individual query value, assemble the URL, then entity-encode the assembled string as you write it into the attribute. Doing it the other way round means the ampersand separators become &amp; in the final markup rather than &, and the query string arrives at the server with a literal amp; prefix on every parameter after the first.
There is a third check that is not an encoding at all. Before a URL goes into an href or an src, its scheme must be validated against an allowlist. Percent-encoding does not neutralise javascript:, data: or vbscript:, and neither does entity encoding. Parse the URL, confirm the protocol is one you permit, and reject the value if it is not — then encode.
Search term entered by the user: Tom & Jerry <2026>
Step 1, percent-encode the value:
Tom%20%26%20Jerry%20%3C2026%3E
Step 2, assemble the URL:
/search?q=Tom%20%26%20Jerry%20%3C2026%3E&page=1
Step 3, entity-encode for the attribute:
<a href="/search?q=Tom%20%26%20Jerry%20%3C2026%3E&page=1">results</a>
Wrong order (entity first, percent second) yields &amp;page=1,
and the server receives a parameter literally named "amp;page".Context-correct encoding is the defence; filtering is not
Every few years someone proposes to solve cross-site scripting by rejecting input that looks dangerous — strip the word script, remove angle brackets, block the string onerror. This has never worked, and the reasons are structural rather than a matter of writing a better list. HTML parsing is extremely forgiving: tags can be split, attributes can be unquoted, characters can be written as entities that the parser resolves before it evaluates anything, and the set of attributes that execute code grows with every specification. A blacklist enumerates badness in a language designed to have infinite ways to say the same thing.
Output encoding inverts the problem. Instead of guessing which inputs are dangerous, you guarantee that whatever the input was, it is inserted as data rather than as markup. That guarantee holds for payloads nobody has invented yet, which is the property a filter can never have. The corollary is that the encoding must be chosen by destination — the same reason the table above exists — and must happen at the last possible moment, in the template, not somewhere upstream where the destination is unknown.
In practice this means leaning on tools that understand context rather than on a helper function. Modern template engines escape by default and know which context they are in; React, Vue and Angular escape interpolated values automatically and require an explicit, greppable opt-out to emit raw HTML. When markup genuinely must be accepted from a user — a rich-text field, a Markdown comment — the answer is a real sanitiser with an allowlist of elements and attributes, such as DOMPurify, applied to the parsed document, not a regular expression applied to a string. And a Content Security Policy is worth having as the layer that limits the damage when one of the above is missed.
Finally, be clear about what a tool on a page like this can and cannot tell you. An entity encoder converts a string so you can inspect or paste it. An HTML preview renders markup so you can see what it produces. Neither one audits your application: they cannot see your templates, do not know which context a value will land in, and a preview that looks harmless proves nothing about the code path that will actually emit the value in production. Use them to understand the mechanism; use code review, a sanitiser and a CSP to be safe.
- Store the original value; encode at output, in the template, once.
- Choose the encoding by destination context, not by the shape of the input.
- Quote every attribute, and validate URL schemes against an allowlist before encoding.
- Accept user markup only through a parser-based sanitiser with an element and attribute allowlist.
- Treat a preview or encoder tool as an inspection aid, never as a security check.
What to remember
- Decide the escape by where the value lands — element text, attribute, URL, script or style — because one escape function cannot be correct for all of them.
- Encode at output rather than at input, so the stored value stays searchable and no second layer can double-escape it.
- Always quote attribute values, always escape the ampersand first when escaping by hand, and prefer ' over ' for portability.
- For a link, percent-encode the value, then entity-encode the assembled URL, and validate the scheme separately because no encoding neutralises javascript:.
- Rely on a context-aware template engine and a parser-based sanitiser for anything that must accept markup; a preview tool shows you output, it does not audit your application.