Cross-Site Scripting (XSS) — the three types, and why escaping matters
XSS is SQL injection's sibling vulnerability, aimed at the browser instead of the database — untrusted input gets rendered as if it were trusted HTML/JavaScript, and the fix is the same underlying idea: never let attacker-controlled data be interpreted as code.
4 min read
The core mechanism: rendering untrusted input as HTML instead of text
# A comment section rendering user input DIRECTLY into the page's HTML
comment = request.form["comment"] # attacker types: <script>steal_cookies()</script>
html = f"<div class='comment'>{comment}</div>"
# The browser doesn't know this <script> tag came from a user — it just
# sees valid HTML and RUNS it, exactly like any other script on the pageIf a comment field's content is inserted directly into the page's HTML without being escaped, and an attacker submits <script>...</script> as their comment, the browser parses that submitted text as a real <script> tag and executes it — with the exact same privileges as any other JavaScript running on that page, including access to cookies, local storage, and the ability to make requests as the logged-in user. This is the entire mechanism of XSS: attacker-supplied text gets interpreted by the browser as code, not displayed as the plain text it was supposed to be.
Reflected XSS: the malicious script comes from the current request itself
A search page renders: "You searched for: {query}", taking `query`
directly from the URL — an attacker crafts a link:
https://example.com/search?query=<script>steal_cookies()</script>
and sends it to a victim. The victim clicks it, the PAGE reflects the
script back into the response, and the victim's OWN browser runs it,
in the victim's OWN authenticated session
Reflected XSS happens when the malicious payload is part of the request itself (a URL parameter, a form submission) and the server immediately reflects it back into the response without escaping — the attack has to be delivered to a specific victim (usually via a crafted link), but it executes with that victim's own session and cookies, since it runs inside a genuinely legitimate page load on the real site.
Stored XSS: the payload gets saved and served to every future visitor
An attacker posts a comment containing <script>steal_cookies()</script>.
The comment is SAVED to the database. Every user who later views that
page — not just the attacker — has the script execute in THEIR browser,
with THEIR session
Stored XSS is more severe than reflected: the malicious payload is saved (in a database, a file, anywhere persisted) and served to every visitor who views the affected content, with no need to trick any individual victim into clicking a crafted link — anyone who simply views a comment section, a user profile, or any page displaying the stored, unescaped content becomes a victim automatically.
DOM-based XSS: the vulnerability lives entirely in client-side JavaScript
// The server never sees this at all — the vulnerability is 100% client-side
const params = new URLSearchParams(window.location.search);
document.getElementById("welcome").innerHTML = "Welcome, " + params.get("name");
// A URL like ?name=<img src=x onerror=steal_cookies()> executes entirely
// in the browser, with NO server-side code involved in the vulnerability at allDOM-based XSS happens entirely within client-side JavaScript — the page's own script reads untrusted data (from the URL, from document.referrer, from any source the attacker can influence) and writes it into the DOM using an API like innerHTML that interprets the content as HTML, with no server round-trip involved in the vulnerable code path at all. This is a real, distinct category worth knowing specifically because server-side output escaping (the general fix, covered next) doesn't help here — the server never processed this data at all; the fix has to happen in the client-side JavaScript itself.
The fix: escape untrusted output, don't trust that input was already "clean"
# Escaping — converting HTML-special characters to their literal text equivalents
import html
safe_comment = html.escape(comment) # <script> becomes <script> — DISPLAYED as text, not executed// Modern frameworks escape by default — React, for instance:
<div>{comment}</div> // React escapes this automatically — safe, comment is always rendered as TEXT
<div dangerouslySetInnerHTML={{ __html: comment }} /> // BYPASSES escaping — genuinely dangerous, named for exactly this reasonEscaping converts HTML-special characters (<, >, &, ") into their literal text equivalents (<, >, &, ") before inserting untrusted content into HTML — the browser then displays the escaped text exactly as written, rather than parsing it as markup. Modern frontend frameworks (React, Vue, Angular) escape content by default when rendering — this is precisely why React's own bypass method is deliberately named dangerouslySetInnerHTML, a real, explicit warning label on the one API that skips this protection.
Further reading
Check your understanding
A quick comprehension check — not tracked, not graded, just for you.
1. What's the core mechanism that makes XSS possible?
2. What's the key difference between reflected and stored XSS?
3. Why doesn't server-side output escaping fix DOM-based XSS?