Stored XSS is the persistent form of cross-site scripting, where an attacker's script is saved in your application's data store and then served back to everyone who views the affected page. That persistence is what makes it more dangerous than reflected XSS: the attacker plants the payload once, and the application delivers it to victims on its own, no phishing link required. If a comment field, a profile bio, or a support ticket accepts input that later renders as HTML, stored XSS is the risk you are managing.
This guide explains the mechanism conceptually and focuses on detection and remediation. There are no working exploit payloads here — the goal is to help you find and fix the flaw, not to weaponize it.
How stored XSS works
The pattern has three steps. An attacker submits input containing markup or script through some normal channel — a comment, a display name, a product review. The application saves that input to its database without neutralizing it. Later, when any user loads a page that renders the stored value, the browser receives the attacker's markup mixed into the page's HTML and executes it in that user's session.
The root cause is always the same: data crosses from an untrusted source into an HTML context without being encoded for that context. The browser has no way to tell "content the developer meant" from "content an attacker injected" — it just parses the HTML it received.
Because the script runs in the victim's authenticated session, its capabilities are whatever the victim can do: read the page's contents, make requests as that user, exfiltrate session tokens if they're reachable, or pivot to actions the user is authorized for. When the affected page is an admin dashboard, a single stored payload can compromise the whole application.
Why "stored" is worse than "reflected"
Reflected XSS needs the attacker to get a victim to click a crafted link — a delivery problem that limits reach. Stored XSS removes that step. The payload lives in your data and is served automatically. A stored payload in a widely viewed field, like a public username shown across a site, can hit every visitor. That combination of automatic delivery and broad reach is why stored XSS consistently ranks as the higher-severity variant.
Detecting stored XSS
Finding it takes a mix of approaches, because no single method catches everything.
Dynamic scanning. A DAST tool can test for stored XSS by submitting benign marker values into inputs and then checking whether those markers come back unencoded on the pages that render them. This "inject here, observe there" flow is exactly the kind of stateful test that separates a real scanner from a header checker. Configuring it with authentication matters, since much of the injectable surface — profiles, tickets, comments — sits behind a login.
Code review and static analysis. Trace where user input flows into output. The dangerous sinks are places that render raw HTML: innerHTML, dangerouslySetInnerHTML in React, template constructs that disable auto-escaping, direct string concatenation into a response. Static analysis tools flag these source-to-sink flows so a reviewer can confirm whether encoding happens in between.
Manual testing. A human can reason about second-order cases automated tools miss — input that's stored in one format and rendered in a different context later, or values that pass through several transformations before display.
Remediating stored XSS
The fixes are well established. The mistake teams make is relying on only one of them.
Context-aware output encoding is the primary defense. Encode data at the point it is written into a page, according to the context it lands in. A value placed in HTML body text needs HTML-entity encoding; a value placed in an attribute, a URL, or a JavaScript context needs different encoding for each. Modern frameworks auto-escape by default in templates — the vulnerabilities appear where developers bypass that with a raw-HTML sink.
// Risky: assigns untrusted data as raw HTML, parsed and executed by the browser
element.innerHTML = userProvidedComment;
// Safer: treated as text, never parsed as markup
element.textContent = userProvidedComment;
Input validation as a supporting control. Validate and constrain input on the way in — reject or normalize what doesn't fit the expected shape. This reduces the attack surface but is not sufficient alone, because the same data may render safely in one context and dangerously in another. Encode on output regardless.
Content Security Policy as defense in depth. A well-configured CSP that disallows inline scripts can prevent an injected payload from executing even if one slips through your encoding. It is a backstop, not a substitute for encoding — treat it as the layer that limits the blast radius when something else fails.
Sanitize rich-text HTML when you must allow it. If a feature genuinely needs users to submit formatted HTML, run it through a vetted, maintained sanitizer library rather than a homegrown blocklist. Blocklists of "bad tags" are bypassed reliably; allowlist-based sanitizers are the maintained approach. Keep that sanitizer patched — an SCA tool helps you notice when a security-relevant dependency like a sanitizer goes stale or picks up a known issue.
A remediation checklist
Walk this when you're closing out a stored XSS finding:
- Encode the value for its exact output context, not just generically.
- Confirm every render path of that data is fixed, not only the one in the report.
- Add a CSP that blocks inline script as a backstop.
- If rich HTML is required, route it through a maintained sanitizer.
- Add a regression test that stores a marker and asserts it renders encoded.
That last step matters: stored XSS reappears easily when someone adds a new page that renders the same field through a raw-HTML sink. A test that fails on unencoded output keeps the fix from silently regressing.
FAQ
What is the difference between stored and reflected XSS?
Reflected XSS reflects the malicious input straight back in the response to a single crafted request, so the attacker must trick a victim into clicking a link. Stored XSS saves the payload server-side and serves it to everyone who views the affected page, which makes delivery automatic and the impact broader.
Is input validation enough to stop stored XSS?
No. Input validation reduces the attack surface but can't be the only control, because the same stored value may be safe in one output context and dangerous in another. Context-aware output encoding at render time is the primary defense; validation and CSP are supporting layers.
How do I detect stored XSS automatically?
Use a dynamic scanner that submits benign marker values into inputs and then checks whether those markers appear unencoded on the pages that render them. Configure it with authentication so it reaches profiles, comments, and other logged-in surfaces. Pair it with static analysis to trace input-to-output flows in code.
Does a Content Security Policy replace output encoding?
No. A CSP that blocks inline scripts is valuable defense in depth and can stop a payload from executing, but it is a backstop. Output encoding remains the primary fix, because CSP can be misconfigured or bypassed, and not every context is covered by script restrictions.