What it is
Cross-site scripting is any way an attacker gets their JavaScript running on your page, with your origin, your cookies and your DOM. Once that happens there is no boundary left: they can read the page, call your API as the user, change what is displayed, and send it all somewhere else.
It is nearly always the same root cause. Somewhere, text that came from a user was put into a page in a place where the browser parses code.
Three kinds
The third is now the most common, because so much rendering happens in the browser. It never appears in a server log and no amount of server-side escaping touches it.
| Kind | The payload lives | Example |
|---|---|---|
| Stored | in your database | a product review containing a <script> tag, shown to every visitor |
| Reflected | in the URL | ?q=<img onerror=…> echoed into the results page, sent as a link |
| DOM-based | never leaves the browser | element.innerHTML = location.hash — no server involved at all |
Escaping is contextual
A function called escape() that does one of these is a function that is wrong in the other three. In HTML text, " is harmless; inside an attribute it ends the attribute. In an href, entities do nothing at all against javascript:.
<div> HERE </div> → & < > become entities
<div title="HERE"> → also " and '
<a href="HERE"> → must be a safe scheme, and encoded
<script> var x = "HERE" </script> → JS string escaping — and still risky
<div style="width: HERE"> → CSS escaping — avoid entirely
There is no reliable escaping for "inside a <script> tag" — </script> inside a string ends the block, and the parser rules are full of edge cases. Pass data as JSON in a <script type="application/json"> block and read it with JSON.parse(el.textContent), or put it in a data- attribute.
The safe defaults
Modern frameworks made XSS rare by making the safe thing the default and the unsafe thing long to type. The remaining bugs cluster in exactly three places: innerHTML (and the framework equivalent), href/src built from input, and anything rendering HTML that came from a rich-text editor.
element.textContent = userInput; // safe: never parsed as HTML
element.setAttribute("title", userInput); // safe
element.innerHTML = userInput; // the bug
// React, Vue, Svelte, Angular all escape by default:
<p>{userInput}</p> // safe
<p dangerouslySetInnerHTML={{ __html: userInput }} /> // you asked for it
When you really do need HTML
Write the list of what is allowed, not a list of what is banned — a blocklist is a guess about an attacker's imagination. And use a maintained library: the edge cases (<svg>, <math>, mutation XSS, namespace confusion) are genuinely beyond a regular expression.
import DOMPurify from "dompurify";
element.innerHTML = DOMPurify.sanitize(comment, {
ALLOWED_TAGS: ["b", "i", "em", "strong", "a", "p", "ul", "li"],
ALLOWED_ATTR: ["href"],
});
The layers behind the escaping
- CSP —
script-src 'self'means an injected inline script does not run even when your escaping fails. This is the single best second line. - HttpOnly cookies — a successful XSS can still act as the user, but cannot copy the session out.
- Trusted Types — makes
innerHTMLthrow unless the value went through a policy you wrote. Chrome and Edge today. - Frameworks — keep the escaping on. Every
dangerouslySetInnerHTMLdeserves a comment saying why.