The three cross-site scripting classes, separated by where the payload lives between injection and execution — and why the taxonomy is messier than three boxes.
All three end the same way: attacker-controlled script executing in a victim's browser under your origin. They differ in the route the payload takes to get there:
One distinction is worth holding onto before the details, because it explains most of the confusion around these terms: stored and reflected describe what the server does, while DOM-based describes where the bug is. Those are two different axes, not three slots on one.
| Stored | Reflected | DOM-based | |
|---|---|---|---|
| Where the payload lives | Server-side storage | The request itself | The browser only |
| Does the server see it | Yes — and keeps it | Yes — and echoes it | No |
| Where the flaw is | Server-side rendering | Server-side rendering | Client-side JavaScript |
| Victim action needed | Visit a normal page | Click a crafted link | Click a crafted link |
| Blast radius | Every viewer, repeatedly | One victim per click | One victim per click |
| Typical sink | Template output | Template output | innerHTML, eval, document.write |
| How it is found | Marker injection, then crawl | Parameter fuzzing | Reading JS or browser taint tracking |
| Visible to a WAF | On the way in | On the way in or out | Never |
| Visible in server logs | Yes | Yes | No |
| Server-side encoding fixes it | Yes | Yes | No |
Input is saved — a comment, a display name, a support ticket, a filename — and later rendered into a page without encoding appropriate to its context. Every visitor to that page executes it, with their own session.
It is the most severe of the three for a structural reason: it needs no delivery mechanism. There is no link to distribute and no victim to convince, so the whole social-engineering step that caps reflected XSS simply does not apply. And the impact scales with who visits: the same flaw in a user-facing comment field is serious, while in an admin dashboard it is privilege escalation, because the next person to load the page is an administrator.
The variant worth knowing is blind stored XSS, where the payload lands somewhere the attacker cannot observe — an internal log viewer, a moderation queue, a generated report. You get no reflected response to confirm anything, so detection depends on out-of-band signalling: the payload calls home, and the callback tells you it fired and where. Any input that eventually reaches a human reviewing it is a candidate, and those paths are rarely covered by tests.
Finding it systematically means submitting uniquely identifiable markers into every field you can write to, then crawling the whole application looking for those markers rendered in an executable context — a very different exercise from fuzzing one parameter at a time.
A parameter from the request is written into the response without encoding — the classic case being a search page that prints No results for <your query>. The server never stores anything; the payload exists only for the lifetime of that request.
Because exploitation requires the victim to follow an attacker-supplied link, reflected XSS is often triaged down. That underrates it in two situations. First, when the vulnerable page is authenticated: the victim is already logged in when the script runs, so the payload acts with their session immediately. Second, when it chains — reflected XSS plus a state-changing endpoint that the injected script can call defeats CSRF token protection entirely, because the script reads the token from the page. The severity of a reflected finding usually rests on what is reachable from the injected context, not on the injection itself.
Of the three this is the easiest to automate. Canary strings through every parameter, then check the response for unencoded reflection, is the approach every scanner implements.
Here the server response is completely clean. The vulnerability is in JavaScript that reads from a source the attacker controls and passes it, unsanitized, to a sink that executes or parses markup.
Common sources: location.hash, location.search, document.referrer, window.name, postMessage payloads, and values previously written to localStorage. Common sinks: innerHTML, outerHTML, document.write, eval, the Function constructor, setTimeout called with a string, jQuery's .html(), and assigning a javascript: URL to href or src.
The single fact that makes DOM XSS different from the other two is this: a URL fragment is never sent to the server. Everything after the # stays in the browser. So a payload delivered through the fragment is invisible to your WAF, absent from your access logs, and unreachable by any server-side sanitization you might add. Defences that work for stored and reflected XSS do not merely underperform here — they never see the input.
That also makes it the hardest to find. There is no response to diff, so detection means reading the JavaScript — often minified and bundled — and tracing data flow, or using browser-side taint tracking (Burp's DOM Invader, or breakpoints on the sinks in DevTools). In bug bounty this is where competition is thinnest, for exactly that reason.
Because stored and reflected classify server behaviour while DOM-based classifies the location of the sink, the three are not mutually exclusive. Stored DOM XSS is entirely ordinary: a value is persisted server-side, returned later by an API call as JSON, and then passed to innerHTML by client-side code. It is stored by persistence and DOM-based by mechanism, and treating the categories as three sealed boxes leads teams to fix it in the wrong place — adding server-side encoding to data that is never rendered server-side.
This is why OWASP's material now leads with a two-axis framing — server XSS versus client XSS, crossed with persisted versus reflected — rather than the familiar three types. The three-way split remains the common vocabulary, and it is what people search for, but the question that determines the fix is narrower: in which execution context does the untrusted value become code?
textContent instead of innerHTML, no string-argument setTimeout, no eval, and Trusted Types to make unsafe sink assignment throw rather than execute.unsafe-inline voids it, and it does not help when the injection lands inside script that the policy already permits.HttpOnly cookies do not stop XSS. They raise the cost of stealing a session token, while the injected script continues to act as the user inside the page — reading data, issuing authenticated requests, changing the account's email.dangerouslySetInnerHTML in React, v-html in Vue, [innerHTML] and bypassSecurityTrustHtml in Angular. On a framework codebase, grepping for those is a faster first pass than any scanner.If you are hunting, the honest ordering is by effort rather than payout. Reflected XSS is the most automatable and therefore the most picked-over. Stored XSS pays better and rewards patience — inject markers broadly, then go looking for where they surfaced, including the places you were not meant to see. DOM XSS has the lowest competition precisely because it requires reading JavaScript that nobody else wants to read, and it is invisible to the scanners everyone else is running.
If you are defending, reverse it. Audit the DOM sinks first, because they are the ones your WAF and your server-side controls cannot see at all.