XSS Attack Types: Stored, Reflected, and DOM Explained
XSS injects scripts through stored posts, reflected links, and DOM sinks.
20+ years shipping production backend systems. Everything here is grounded in real deployments.
- ✓Basic HTML tags and attributes
- ✓How browsers run JavaScript
- ✓Template rendering concepts
- XSS happens when attacker-controlled text is executed as script in a victim's browser instead of shown as plain text
- Stored XSS saves the payload on your server, reflected XSS bounces it off a URL, and DOM XSS triggers inside client-side JavaScript
- Output encoding by context (HTML, attribute, JavaScript) is the main fix for text you display back to users
- Rich HTML needs a sanitizer allow-list, since encoding would break formatting and regex blocklists always miss vectors
- Content-Security-Policy limits damage by blocking inline scripts, but it can't rescue pages that execute injected markup
Imagine a classroom bulletin board where anyone can pin a note. If the teacher reads every note as an order, a prank note saying 'everyone give me your lunch money' sounds official. That's cross-site scripting. Attacker text posted on your site gets treated as instructions by other visitors' browsers. The fix is like putting quotes around every note so it's read as someone's words, never as orders. The fix is output encoding, plus a sanitizer for formatted posts.
Cross-site scripting has haunted web apps for twenty years, and it still shows up in bug bounties every week. The pattern never changes: your app takes text from one person, shows it to another person, and somewhere in between a browser mistakes data for code. The result ranges from defaced comments to stolen sessions and hijacked accounts.
What makes XSS tricky is that it comes in three flavors with different data flows. Stored payloads live in your database and hit every visitor. Reflected payloads ride inside a crafted link and fire once. DOM payloads never touch your server at all; they detonate inside your own JavaScript. One fix doesn't cover all three unless you understand where untrusted data meets a code sink.
The defense order is settled: encode output according to context, sanitize rich HTML with an allow-list library, and enforce Content-Security-Policy as a backstop. Frameworks auto-escape more than they used to, but template helpers, URL handling, and client-side rendering still open gaps.
This guide maps each XSS type to its flow, shows the exact encoding or sanitizer call that stops it, and adds a CSP that shrinks whatever slips through. You'll leave able to classify any finding in minutes and fix it at the right layer.
Stored XSS: When Your Database Becomes the Attack Server
Stored XSS is the most damaging flavor because your own infrastructure delivers the payload. An attacker submits script markup through a comment, bio, or ticket field, your app saves it verbatim, and every later page view serves it back to victims. One poisoned row can hit thousands of sessions before anyone notices, and privileged viewers like support agents or admins are usually the first to trigger it.
The data flow has three stops worth checking: input handling, storage, and rendering. Many teams validate on input but forget the render step, or escape on input and then double-render later. The fix belongs at output time, when you know the exact context the data enters. Encode plain-text fields with the template engine's escaper, and run rich-text fields through an allow-list sanitizer before storage and again at render.
Detection is straightforward once you suspect it. Submit a harmless image-handler probe as a new comment in staging, then load the page in a clean test browser with the console open. If the handler fires, grep for raw-HTML helpers around that template. Purge poisoned rows quickly, but rotate sessions too, since you can't prove stolen tokens weren't already used.
Prevention scales through defaults: escaped output everywhere, sanitized rich text only where formatting is a real requirement, and a test per field that posts script markup. Stored XSS dies out when raw rendering requires a review, not a shortcut.
Reflected XSS: the Crafted Link That Fires Once
Reflected XSS keeps the payload in the request instead of the database. A search box, error page, or redirect echoes a query parameter straight into the response, and the attacker wraps that URL into a phishing link. The victim clicks, the server reflects the script, and the browser runs it in your site's origin with access to cookies and page content.
These flaws cluster around helpful features: search pages that repeat your query, error banners that quote the bad value, and previews that echo draft text. Server logs often hold the evidence, since the payload travels in plain sight inside access log URLs. A quick grep for script tags or event-handler strings in recent logs tells you whether anyone probed those endpoints.
The fix is context-aware output encoding at the exact echo point. HTML-escape text placed between tags, attribute-escape values inside quotes, and avoid reflecting input into JavaScript blocks entirely. Framework helpers usually pick the right encoder when you pass data through them instead of concatenating strings in the template.
Tests should assert on the encoded form, not just the absence of a popup. Request the endpoint with a script probe and check that the response contains the entity-escaped version. Add the check to every endpoint that echoes request data, and reflected XSS stops surviving redesigns.
DOM XSS: the Payload Your Server Never Sees
DOM-based XSS detonates entirely in the browser. Your JavaScript reads an attacker-controlled source such as location.hash, location.search, document.referrer, or a postMessage, then writes it into the page through a dangerous sink like innerHTML, document.write, or eval-adjacent code. The malicious string never crosses your server, so server logs and WAFs see nothing.
Single-page apps hit this pattern often because they render routes, filters, and previews on the client. A hash-driven tab switcher that sets innerHTML from the fragment, a JSONP callback name copied into a script, or a message handler that trusts event origin all create DOM sinks. Frameworks reduce the risk when you use text bindings, but one raw-HTML insertion reopens it.
The fix has two halves. Prefer safe sinks: textContent instead of innerHTML, setAttribute with validated values instead of string-built markup, and JSON parsing instead of eval. When HTML insertion is genuinely needed, sanitize first with DOMPurify and keep the allow-list tight. Validate message origins on every postMessage handler.
Testing needs a browser, not curl. Drive the app with hostile fragments and messages in staging, watch for execution, and assert the DOM contains text nodes rather than new elements. Static rules that flag innerHTML assignments catch regressions in CI before they ship.
Output Encoding by Context: HTML, Attribute, and JavaScript
Encoding is not one function but a family, and picking the wrong member leaves a hole. Text placed between HTML tags needs HTML entity encoding, so < becomes < and the browser shows it instead of parsing it. Values inside quoted attributes need attribute encoding plus the surrounding quotes, because an unquoted attribute can be broken out of with a space alone. Data placed inside a script block needs JavaScript string encoding, which is far stricter and easier to get wrong.
The practical rule is to avoid the third case whenever possible. Don't build script blocks with template variables; pass data through safe channels like JSON in a data attribute or a dedicated endpoint the script fetches. When you must embed a value in script, use the framework's JSON encoder for JavaScript contexts rather than HTML escaping, since the two grammars differ.
Modern template engines auto-escape HTML text by default, which covers the common case as long as you don't bypass it. The bypasses are the audit list: safe filters, raw helpers, triple-brace syntax, and string-concatenated attributes. Each bypass should carry a comment naming the encoder or sanitizer that replaces it.
Review encoding at the sink, not at input time. Data stored raw and encoded per context stays correct when the same value appears in HTML today and in an email template tomorrow. Encode late, encode by context, and test with characters that are special in each grammar.
Sanitizing Rich HTML Without Building Your Own Filter
Some fields genuinely need formatting: bold, links, lists, maybe embedded media. Encoding would destroy that, so rich HTML needs sanitization instead. A sanitizer parses the markup like a browser, drops everything outside an allow-list of tags and attributes, and neutralizes dangerous URLs and event handlers. The allow-list direction matters: unknown constructs are removed by default.
Use a maintained library, never a regex. In browsers, DOMPurify is the standard; in Python, Bleach with explicit tags and attributes; in Java, OWASP's Java HTML Sanitizer. Configure them tightly: a handful of text tags, links with safe protocols, no style or event attributes. Strip rather than escape the rest so rejected markup can't be revived by a second decoding pass.
Sanitize on the way in for storage hygiene and again at render for defense in depth. Validate URLs against http and https schemes, rejecting javascript: and data: payloads. Log what gets stripped on high-risk fields; a spike in stripped scripts is an early warning that someone is probing you.
Test sanitizers with a nasty corpus: handler attributes, SVG animations, nested templates, and encoded variants. Libraries update their bypass lists regularly, so pin versions and track advisories. Your custom regex can't compete with that maintenance burden, so don't try.
Content-Security-Policy: the Backstop That Contains Escapes
Content-Security-Policy is an HTTP header that tells the browser which sources may supply scripts, styles, and other resources. A strict policy like script-src with nonces blocks inline scripts outright, so many injected payloads arrive dead even when an encoding gap let them into the page. It also reports violations, giving you telemetry on probing you would otherwise miss.
Start from deny-by-default and add only what the app needs. Serve scripts from your own origin plus named CDNs, require a per-request nonce on legitimate inline scripts, and forbid object and base-uri abuse that attackers use for escalation. Keep style-src tight too, since CSS injection can exfiltrate data in older browsers.
Roll out in report-only mode first to catalog legitimate violations, fix them, then flip to enforcing on a deadline. Report-only forever, as the incident above showed, protects nothing. Collect reports at a dedicated endpoint and alert on spikes; a burst of violations against one page often precedes a public exploit.
Know the limits. CSP can't save pages that allow arbitrary inline scripts through unsafe-inline, and it doesn't fix injection into script contexts that already run. Treat it as containment and telemetry layered over encoding and sanitization, and re-verify headers after every edge or CDN change.
A Profile Bio Field Ran Scripts in 12,000 Support Sessions
- Auto-escaping covers standard templates, not raw-HTML helpers. Every bypass of the escaper needs a named owner, a sanitizer, and a test that submits script markup.
- Report-only CSP is monitoring, not protection. Promote the policy to enforcing with nonces on a deadline, and treat report-only mode older than one sprint as a bug.
- Support teams are high-value XSS targets because agents open attacker content all day. Give them short-lived sessions and page security on clustered browser anomalies fast.
| File | Command / Code | Purpose |
|---|---|---|
| stored_xss_fix.py | ALLOWED_TAGS = ['b', 'i', 'em', 'strong', 'a'] | Stored XSS |
| reflected_fix.py | from urllib.parse import parse_qs | Reflected XSS |
| sanitize_rich.py | ALLOWED_TAGS = ['b', 'i', 'em', 'strong', 'a', 'ul', 'li', 'p'] | Sanitizing Rich HTML Without Building Your Own Filter |
| csp_check.sh | set -euo pipefail | Content-Security-Policy |
Key takeaways
Common mistakes to avoid
5 patternsMarking user HTML safe to silence the template engine
Filtering XSS with a regex blocklist
Echoing search and error text without encoding
Writing location.hash into innerHTML
Leaving CSP in report-only mode indefinitely
Interview Questions on This Topic
How do stored, reflected, and DOM XSS differ?
Frequently Asked Questions
20+ years shipping production backend systems. Everything here is grounded in real deployments.
That's XSS. Mark it forged?
6 min read · try the examples if you haven't