Home › Security › XSS Attack Types: Stored, Reflected, and DOM Explained
Intermediate 6 min · September 23, 2026
Cross-Site Scripting (XSS): Stored, Reflected, DOM

XSS Attack Types: Stored, Reflected, and DOM Explained

XSS injects scripts through stored posts, reflected links, and DOM sinks.

N
Naren Founder & Principal Engineer

20+ years shipping production backend systems. Everything here is grounded in real deployments.

Follow
✓ Production
production tested
September 27, 2026
last updated
2,085
articles · all by Naren
Before you start⏱ 14 min
  • ✓Basic HTML tags and attributes
  • ✓How browsers run JavaScript
  • ✓Template rendering concepts
 ● Production Incident 🔎 Debug Guide
⚡Quick Answer
  • 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
✦ Definition~90s read
What is Cross-Site Scripting (XSS)?

Cross-site scripting (XSS) is an injection flaw where attacker-controlled data is executed as script in another user's browser. It differs from SQL injection in its victim: the database isn't tricked, a person's browser is. When your app reflects a comment, a username, or a URL fragment into a page without proper handling, the browser can't tell your markup from the attacker's markup, so it runs both.

★
Imagine a classroom bulletin board where anyone can pin a note.

Stored XSS persists the payload on the server, typically in comments, profiles, or support tickets. Every later visitor loads it and executes it, which makes stored flaws the most damaging. Reflected XSS keeps the payload in the request itself, usually a query parameter echoed into an error page or search result; the victim must click a crafted link, but the payload fires immediately.

DOM-based XSS moves the sink into client-side code: your JavaScript reads location.hash or postMessage data and writes it into the page with a dangerous sink like innerHTML, so the server never sees the attack.

Output encoding is the primary fix for text content. Encoding converts characters like < and & into inert entities at the moment data enters an HTML, attribute, or JavaScript context, using rules specific to that context. Sanitization is the separate fix for rich HTML you intentionally accept: an allow-list library such as DOMPurify or Bleach strips scripts and event handlers while keeping safe tags.

Content-Security-Policy adds a containment layer by telling the browser which script sources are legal and blocking inline scripts by default. A strict nonce policy stops many payloads despite encoding gaps. But CSP is a backstop, not a cure: pages that need inline scripts or that echo into script contexts can still be exploited, which is why encoding and sanitization come first.

Plain-English First

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.

stored_xss_fix.pyPYTHON
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import html
import bleach

ALLOWED_TAGS = ['b', 'i', 'em', 'strong', 'a']
ALLOWED_ATTRS = {'a': ['href', 'title']}

def render_comment_plain(author: str, body: str) -> str:
    # Plain text: encode at render time for the HTML context.
    return f"<div class='comment'><b>{html.escape(author)}</b><p>{html.escape(body)}</p></div>"

def render_bio_rich(bio_html: str) -> str:
    # Rich text: allow-list sanitize, then render the cleaned HTML.
    clean = bleach.clean(bio_html, tags=ALLOWED_TAGS, attributes=ALLOWED_ATTRS, strip=True)
    return f"<div class='bio'>{clean}</div>"

probe = '<img src=x onerror=alert(1)>'
print(render_comment_plain('ana', probe))
print(render_bio_rich('<b>Hi</b>' + probe))
⚠ Probe Payloads Stay in Staging
Test script probes only against disposable staging data with a clean test browser. Never fire payloads at production sessions.
📊 Production Insight
The support-desk breach above served payloads in 12,000 views from just 40 poisoned profiles. Symptom: agent browsers acting strangely on customer pages. Rule: treat every raw-HTML helper as a stored-XSS suspect until it has a sanitizer and a test.
🎯 Key Takeaway
Stored payloads persist and strike every viewer. Encode at render time, sanitize rich text, and probe each user-content field with harmless markup.

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.

reflected_fix.pyPYTHON
1
2
3
4
5
6
7
8
9
10
11
12
import html
from urllib.parse import parse_qs

def search_page(query_string: str) -> str:
    params = parse_qs(query_string)
    q = params.get('q', [''])[0]
    # Encode for the HTML text context before echoing the query.
    safe = html.escape(q, quote=True)
    return f'<h1>Results for &quot;{safe}&quot;</h1><p>No matches found.</p>'

# Probe stays inert text instead of live markup.
print(search_page('q=<script>alert(1)</script>'))
📊 Production Insight
Reflected flaws often hide in search and error pages nobody owns. Symptom: payloads visible in access-log URLs. Rule: encode every echo of request data and assert the escaped form in tests.
🎯 Key Takeaway
Reflected payloads ride links and fire on click. Encode echoed input by context and verify with probe requests in tests.

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.

📊 Production Insight
DOM flaws bypass server logs entirely, so teams chase ghosts until they test in a real browser. Symptom: execution with no matching server request. Rule: grep client code for innerHTML-class sinks and replace or sanitize each one.
🎯 Key Takeaway
DOM XSS flows from browser source to script sink without server contact. Use safe sinks, sanitize the rest, and test with hostile fragments.

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 &lt; 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.

📊 Production Insight
Most bypasses come from one safe-filter added to silence a warning. Symptom: encoded pages with a single unescaped variable. Rule: require a named encoder on every escaper bypass during review.
🎯 Key Takeaway
Match the encoder to the sink grammar and avoid script-context interpolation. Late, contextual encoding covers every reuse of the data.

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.

sanitize_rich.pyPYTHON
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import bleach

ALLOWED_TAGS = ['b', 'i', 'em', 'strong', 'a', 'ul', 'li', 'p']
ALLOWED_ATTRS = {'a': ['href', 'title']}
ALLOWED_PROTOCOLS = ['http', 'https']

def sanitize_comment(dirty: str) -> str:
    return bleach.clean(
        dirty,
        tags=ALLOWED_TAGS,
        attributes=ALLOWED_ATTRS,
        protocols=ALLOWED_PROTOCOLS,
        strip=True,
    )

probes = [
    '<b>hello</b><script>alert(1)</script>',
    '<a href="javascript:alert(1)">click</a>',
    '<img src=x onerror=alert(1)>',
]
for p in probes:
    print(sanitize_comment(p))
📊 Production Insight
Hand-rolled regex filters fail on nested and encoded vectors every time. Symptom: blocklist of script and iframe while handlers pass through. Rule: replace custom filters with an allow-list library and test with handler and SVG probes.
🎯 Key Takeaway
Rich HTML needs an allow-list sanitizer, applied at input and render. Maintained libraries beat regexes and stay current with bypasses.

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.

csp_check.shBASH
1
2
3
4
5
6
7
8
9
#!/usr/bin/env bash
set -euo pipefail
TARGET="${1:-https://example.com/profile}"
echo "== CSP headers for $TARGET =="
curl -sI "$TARGET" | grep -i '^content-security-policy' || {
  echo 'FAIL: no enforcing CSP header found' >&2
  exit 1
}
echo 'OK: enforcing CSP header present.'
📊 Production Insight
Nine months in report-only mode meant 12,000 payload deliveries with zero blocks. Symptom: CSP reports logged but unenforced. Rule: time-box report-only to one sprint, then enforce with nonces and alert on violations.
🎯 Key Takeaway
Enforcing CSP with nonces contains what encoding misses and reports probing. Ship it as a backstop, not a substitute.
● Production incidentPOST-MORTEMseverity: high

A Profile Bio Field Ran Scripts in 12,000 Support Sessions

Symptom
On a Tuesday morning, three support agents reported popups and sluggish ticket pages within 20 minutes of each other. The helpdesk app showed no server errors, but browser consoles on agent machines logged script errors from a profile page. By noon, the security team confirmed that viewing any of 40 poisoned customer profiles executed script in the agent's session, and about 12,000 ticket views had served the payload over 6 days.
Assumption
Developers believed the frontend framework auto-escaped everything, so the bio field was marked safe for rich text without a sanitizer. They also assumed an allow-list of script sources existed, but the CSP header was still in report-only mode from a rollout nine months earlier. Support assumed the popups were a browser extension issue and kept working through them for days.
Root cause
The profile bio was rendered with a raw-HTML helper that skipped escaping, and the value was stored unfiltered in the database: a textbook stored XSS sink. Attacker markup with an image error handler executed in every agent session that opened the profile, firing requests that harvested session-adjacent data. Report-only CSP logged violations but blocked nothing, so the payload ran freely in 12,000 views before anyone connected the popups to the ticket queue.
Fix
The team took profile editing offline, purged the 40 poisoned bios from the database, and invalidated all 210 active support sessions. They switched the bio template to context-aware encoding, added DOMPurify sanitization for the small subset of formatting tags, and shipped an enforcing CSP with nonces that blocks inline handlers. A scan of every other raw-HTML helper found 2 more unguarded fields, which got the same treatment plus regression tests with script payloads.
Key lesson
  • 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.
Production debug guideFive checks that classify the XSS type and pin the fix at the right layer.5 entries
Symptom · 01
Script payload saved in a comment or profile executes for every later visitor
→
Fix
Confirm stored flow: submit <img src=x onerror=console.log(1)> as plain text in staging and reload the page in a test browser. If it fires, find the render helper: grep -rn 'safe\|| raw\|v-html\|dangerouslySetInnerHTML' templates/ src/ and switch that field to escaped output. Purge poisoned rows and force session rotation for affected users.
Symptom · 02
Payload only fires when opening a crafted link with query parameters
→
Fix
Confirm reflected flow: load /search?q=<script>console.log(1)</script> in staging and view source to see if the parameter is echoed unencoded. Fix with context-aware encoding at the echo point, and add a test asserting the encoded form appears. Check server logs for real-world hits carrying script strings to scope exposure.
Symptom · 03
Payload in the URL fragment after # executes though the server never sees it
→
Fix
Confirm DOM flow: set location.hash to an image-handler string and watch for execution without any network request. Search client code: grep -rn 'innerHTML\|outerHTML\|document.write\|insertAdjacentHTML' src/ and replace the sink with textContent or a sanitized assignment. Retest with the fragment payload until nothing executes.
Symptom · 04
Rich-text field needs bold and links but scripts keep slipping through
→
Fix
Stop hand-rolling regex filters and add an allow-list sanitizer: DOMPurify.sanitize(dirty, {ALLOWED_TAGS: ['b','i','a']}) in JS or bleach.clean with explicit tags in Python. Test with handler attributes, javascript: URLs, and SVG vectors. Reject anything the sanitizer strips unexpectedly and log it for review.
Symptom · 05
Unsure whether the deployed pages actually enforce script blocking
→
Fix
Inspect response headers: curl -sI https://your-app/profile | grep -i content-security-policy and confirm an enforcing policy with script-src and nonces, not report-only. Fix header injection at the edge or middleware, then verify in the browser console that an inline test script is blocked with a CSP violation.
XSS Types and Defenses Compared
Root CauseHow to ConfirmFixPrevention
Stored payload rendered rawPost handler probe; fires on reloadEncode output; sanitize rich fieldsTest every user-content field with markup
Reflected parameter echoedProbe URL echoed unencoded in sourceContext-aware encoding at echo pointAssert escaped form for echo endpoints
DOM sink like innerHTMLFragment executes, server sees nothingSafe sinks plus DOMPurifyLint client code for dangerous sinks
No enforcing CSP backstopHeader missing or report-onlyEnforce nonce-based policyAlert on violation-report spikes
⚙ Quick Reference
4 commands from this guide
FileCommand / CodePurpose
stored_xss_fix.pyALLOWED_TAGS = ['b', 'i', 'em', 'strong', 'a']Stored XSS
reflected_fix.pyfrom urllib.parse import parse_qsReflected XSS
sanitize_rich.pyALLOWED_TAGS = ['b', 'i', 'em', 'strong', 'a', 'ul', 'li', 'p']Sanitizing Rich HTML Without Building Your Own Filter
csp_check.shset -euo pipefailContent-Security-Policy

Key takeaways

1
Stored XSS persists server-side, reflected rides links, DOM detonates in client JavaScript sinks.
2
Encode plain text at output by context; sanitize rich HTML with allow-list libraries instead.
3
Never build script blocks from templates; pass data via encoded attributes or fetched JSON.
4
Replace innerHTML-class sinks with textContent or sanitized assignment, and check message origins.
5
Enforce nonce-based CSP as containment and telemetry, not as the primary fix.
6
Probe every user-content field with script markup and lint for escaper bypasses in CI.

Common mistakes to avoid

5 patterns
×

Marking user HTML safe to silence the template engine

Symptom
One safe filter disables escaping for the whole field, and every stored value becomes live markup.
Fix
Remove the bypass or route the field through an allow-list sanitizer. Require review for every escaper bypass.
×

Filtering XSS with a regex blocklist

Symptom
Script tags get blocked while event handlers, SVG, and encoded variants sail through untouched.
Fix
Replace regexes with DOMPurify or Bleach allow-lists. Test with handler, javascript: URL, and SVG probes.
×

Echoing search and error text without encoding

Symptom
Crafted links execute on click, and payloads pile up visibly in access logs.
Fix
Encode every echo of request data by context. Add probe tests asserting the escaped output.
×

Writing location.hash into innerHTML

Symptom
Fragment payloads execute with no server request, so logs and WAFs show nothing at all.
Fix
Use textContent or sanitized HTML. Validate postMessage origins and test with hostile fragments.
×

Leaving CSP in report-only mode indefinitely

Symptom
Violations get logged for months while payloads keep executing in real browsers.
Fix
Time-box report-only to one sprint, then enforce with nonces and page on violation spikes.
INTERVIEW PREP · PRACTICE MODE

Interview Questions on This Topic

Q01JUNIOR
How do stored, reflected, and DOM XSS differ?
Q02JUNIOR
When do you encode output versus sanitize it?
Q03SENIOR
A comment field needs links and bold. How do you make it safe?
Q04SENIOR
Your app uses innerHTML for a hash-driven widget. What's the fix?
Q05SENIOR
CSP is enforced, yet XSS persists. How is that possible?
Q01 of 05JUNIOR

How do stored, reflected, and DOM XSS differ?

ANSWER
Stored payloads persist on the server and hit every viewer; reflected payloads ride a crafted request and fire once; DOM payloads flow from browser sources like location.hash into script sinks without server contact. The flow decides where you fix it.
FAQ · 6 QUESTIONS

Frequently Asked Questions

01
Does input validation stop XSS on its own?
02
My framework auto-escapes. Am I safe?
03
Is innerText the same as textContent for safety?
04
Can CSP replace output encoding?
05
How should I handle javascript: URLs in user links?
06
Where do I look first when XSS is reported?
N
Naren Founder & Principal Engineer

20+ years shipping production backend systems. Everything here is grounded in real deployments.

Follow
✓ Verified
production tested
September 27, 2026
last updated
2,085
articles · all by Naren
🔥

That's XSS. Mark it forged?

6 min read · try the examples if you haven't

←
Previous
SQL Injection: How a Login Form Leaks the Whole Database
1 / 2 · XSS
Next
CSRF Token Mismatch Error — Cause and Correct Fix
→