Stored XSS: how to find it, prove it and fix it
Stored XSS (also called persistent XSS) happens when an application saves untrusted input and later shows it to other users without encoding it, so the browser runs the saved script in each viewer's session. It is usually the most serious XSS type because it needs no crafted link. Fix it by encoding on output, sanitizing the HTML you must accept, and adding a Content Security Policy.
Stored XSS, also called persistent cross-site scripting, is a flaw where an application saves untrusted input and later shows it to other users without encoding it. The browser runs the saved content as code in each viewer's session. OWASP's Web Security Testing Guide calls it the most dangerous type of cross-site scripting. The weakness is CWE-79.
This page is the stored-specific guide. For the basics of XSS, read cross-site scripting first. Test only systems you own or have written authorization to test.
How does stored XSS differ from reflected and DOM-based XSS?#
Stored XSS separates the moment of injection from the moment of execution. The attacker submits data once. The application keeps it. Every later viewer of the page that renders it runs the script.
| Stored | Reflected | DOM-based | |
|---|---|---|---|
| Payload saved on the server | Yes | No | No |
| Victim must open a crafted link | No | Yes | Usually yes |
| Reaches many users from one request | Yes | No | No |
| Where the flaw sits | Server output (or client rendering of saved data) | Server output | Client JavaScript |
| Detected by | Plant a marker, then search every view | Send a marker, read the same response | Trace sources to sinks in the browser |
PortSwigger's definition matches this split. OWASP lists database, message forum, visitor log and comment field as typical storage places. The page on DOM-based XSS covers the client-side type. A stored value can also reach a DOM sink, so one bug can be both stored and DOM-based.
How does a stored XSS attack work?#
The attacker writes the payload into a field. The application saves it. A victim opens a page that prints the field. The example below is fictional.
Demonstration only: Harbor Notes, a fictional app. No real customer system was tested.
Harbor Notes at https://notes.example.com lets members comment on shared notes. A comment view prints the saved text without escaping.
Step 1, the author saves a comment with a harmless proof string:
POST /api/notes/42/comments HTTP/1.1
Host: notes.example.com
Content-Type: application/json
Cookie: session=<author session>
{"text": "<img src=x onerror=alert(document.domain)>"}
Step 2, the server stores the text as sent:
{"id": 311, "note_id": 42, "text": "<img src=x onerror=alert(document.domain)>"}
Step 3, a second member opens /notes/42. The server returns:
<li class="comment"><img src=x onerror=alert(document.domain)></li>
The image fails to load, and the error handler runs in the second member's session on notes.example.com.
The save request returned success in step 2. That is not proof. The proof is step 3: the code ran in a different session.
Where does stored XSS hide?#
Stored XSS hides in every place where data goes in through one door and comes out through another. Testers map both doors.
Common input points: profile display name, bio, comments, ticket titles, product reviews, file names on upload, shipping addresses, support chat messages, imported CSV rows, webhook payloads, API fields that mobile apps never show.
Common render points: the same page for the author, other users' feeds, admin dashboards, email templates, PDF or CSV exports, audit log viewers, notification toasts, search suggestions.
The admin view is the one most often missed. A user can write a field that no user page ever shows, and an administrator then sees it in a back-office table. This is sometimes called second-order or blind XSS. The data passes every user-facing page safely and still runs in the one view that has the highest privileges. A related case is a file upload whose name or content type is rendered back. See file upload vulnerabilities.
How do you rate the severity of stored XSS?#
Severity depends on who views the data and what that viewer can do. Use the viewer as the first factor.
| Who views the stored value | What the script can reach | Typical rating factor |
|---|---|---|
| Only the author | The author's own session | Low. Often called self-XSS |
| Other users of the same role | Their data and actions | Higher |
| Users of other tenants | Cross-tenant data | Higher still. See multi-tenant isolation testing |
| Administrators or support staff | Admin functions | Highest |
| Anonymous visitors on a public page | Any visitor's browser | High, plus reputation damage |
Our reports use a four-level scale (Critical, High, Medium, Low) with a written definition for each level. We add CVSS only when a buyer asks for it during scoping.
How do penetration testers find and prove stored XSS?#
Testers prove stored XSS by showing that data saved in one request ran in a different session. A bare success response from the save call is not evidence. The steps follow our method for web application penetration testing.
- Map inputs. List every field that the application saves, in the UI and in the API. Include fields the UI never sends.
- Plant a unique marker in each field, for example
zq7-profile-name. Use a different marker per field, so you can tell later which input reached which page. - Map outputs. Open every view with two accounts, each in a separate browser profile, and with an admin account if scope allows. Search the rendered DOM for each marker.
- Record the context for each hit: HTML body, attribute, JavaScript block, URL, or inside an email or export.
- Send a minimal, context-specific proof such as
alert(document.domain)orprint(). Use only test-owned accounts and records, marked as test data. - Run controls. Send the same field with plain text, and with a nonexistent record. Confirm that the plain text appears encoded and that the proof string does not run through a path that has the right encoding.
- Read back from a second session. Load the page as the second user and watch the code run in a real browser. Label the evidence "Browser-observed".
- Save the evidence: the save request, the stored record, the render request, the HTML excerpt, the browser version and a screenshot.
- Clean up. Delete the test records, or list them in the report with their IDs.
For blind cases, where no page that you can open shows the data, scope may allow an out-of-band callback to a host the tester controls. The callback sends only a marker and the page URL. It never takes cookies, tokens or user data. If scope does not allow it, the report says the render point could not be tested.
Controls that held also go in the report. If comments are encoded but the display name is not, the report says so. Then the fix does not remove the encoding that works.
How do you fix stored XSS?#
Store the data raw and encode it every time you output it. Encoding on input fails because the same value reaches HTML, attributes, JSON, email and CSV, and each needs its own encoding. OWASP's cheat sheet says to prefer safe APIs that treat data as text, to use context-specific encoding where syntax is interpreted, and to sanitize user HTML with a maintained library.
Express with EJS. Escaped output is <%= %>:
// write path: store the value as sent, after length and type checks
app.post('/notes/:id/comments', async (req, res) => {
const text = String(req.body.text ?? '').slice(0, 2000);
await db.comments.insert({ noteId: req.params.id, userId: req.user.id, text });
res.status(201).end();
});
// read path (views/note.ejs): <li><%= comment.text %></li> escaped
Django. Variables are auto-escaped, and Django's docs describe how to turn that off for a variable or block. Do not do that for user data:
Print the variable with the normal double-curly output tag. The safe filter, mark_safe() and the autoescape off tag all turn escaping off, so do not use them on user data:
{% autoescape on %}
... comment text printed with the normal variable tag ...
{% endautoescape %}
Laravel. Blade's double-curly echo statements send the value through htmlspecialchars (Laravel docs). The {!! !!} syntax prints raw output, so do not use it for user data. In plain PHP, call the function yourself:
<li><?= htmlspecialchars($comment->text, ENT_QUOTES, 'UTF-8') ?></li>
React. JSX escapes text. Do not pass user HTML to dangerouslySetInnerHTML unless a sanitizer cleaned it:
<li>{comment.text}</li>
Client code that builds the DOM from saved data should use textContent. MDN advises against innerHTML for plain text because it handles raw HTML and can be susceptible to XSS:
li.textContent = comment.text;
How do you allow rich text safely?#
Use a maintained sanitizer with a short allow list, and run it on the final HTML. DOMPurify is a widely used choice in JavaScript. On the server, it needs a DOM such as jsdom, and its README says to use the latest jsdom because old versions have known problems:
import DOMPurify from 'dompurify';
import { JSDOM } from 'jsdom';
const purify = DOMPurify(new JSDOM('').window);
const clean = purify.sanitize(userHtml, {
ALLOWED_TAGS: ['b', 'i', 'em', 'strong', 'a', 'p', 'ul', 'ol', 'li', 'code'],
ALLOWED_ATTR: ['href'],
});
Four rules apply to rich text:
- Sanitize with a library, not with your own pattern match.
- Allow few tags and attributes, and set link schemes to
https:andhttp:. - Sanitize at render time, or at both write and render time, so a library update protects old data.
- Keep the library updated. CVE-2020-26870 was a mutation XSS bug in DOMPurify before 2.0.17.
Markdown needs the same care. Most Markdown renderers pass raw HTML through unless you turn that off or sanitize the output.
Which defense layers reduce the damage?#
A Content Security Policy blocks inline scripts that a stored value might add. A policy such as script-src 'self'; object-src 'none'; base-uri 'none' does that on a site with no inline scripts. See Content Security Policy and the MDN CSP guide.
The HttpOnly cookie flag hides the session cookie from scripts. It does not stop the script from acting as the user inside the page. A web application firewall may block known patterns in the save request, but it cannot see which output context the value reaches later. Treat these as extra layers and fix the output encoding.
What real stored XSS cases exist?#
| Date | Case | Lesson |
|---|---|---|
| October 2005 | The Samy worm on MySpace. By its author's account it reached more than 1,000,000 users in under 20 hours (source) | Stored profile content ran in each viewer's session and spread by itself. A filter for some tags and words did not stop it (write-up). |
| 14 March 2019 | CVE-2019-9787: WordPress before 5.1.1 | NVD says mishandled CSRF protection and incorrect processing of link elements in comments led to XSS and then administrator access. Comment content reached an admin session. |
| 18 January 2022 | CVE-2021-44217: Ericsson CodeChecker through 6.18.0 | NVD describes a stored XSS flaw in the comments component of the reports viewer, reachable through the /CodeCheckerService API. API fields need output encoding too. |
Stored XSS testing checklist#
- List every field that is saved, in the UI and in the API.
- Plant a unique marker in each field.
- Open every view as author, other user, other tenant and admin. Search each for the markers.
- Record the output context of each hit.
- Prove execution with a minimal proof string in a real browser, in a second session.
- Run controls: plain text, a nonexistent record, a signed-out view.
- Check exports, emails, PDFs, logs and admin tables as well as web pages.
- Check the sanitizer name, version and allow list for rich text.
- Read the CSP and the cookie flags.
- Retest after the fix with the same save and render requests, and delete test data.
When should you get a professional test for stored XSS?#
Get a professional test when your product has several roles, several tenants, rich text, imports or an admin back office. Stored XSS across these views is hard to find with a scanner, because a scanner cannot always see the page that renders the value. Excession Labs checks input and output paths with test accounts for each role, records browser-observed evidence, and reports controls that held. See web application penetration testing to request a test with written authorization.
Questions
What is the difference between stored and reflected XSS?
Reflected XSS returns the payload from the current request, so the victim must open a crafted link. Stored XSS saves the payload first and serves it to every later viewer, with no link needed. Stored XSS therefore reaches more people and tends to rate higher, especially when an administrator views the saved data.
Is persistent XSS the same as stored XSS?
Yes. Persistent XSS is the older name for stored XSS. Both mean the application keeps the injected data on the server, for example in a database, and sends it to users later. Newer sources, including the OWASP Web Security Testing Guide, use the word stored.
What is blind stored XSS?
Blind stored XSS is a stored flaw where the tester cannot see the page that renders the data, such as an internal admin panel. Testers prove it with an out-of-band callback to a host they control, limited to a harmless marker such as the page URL. They never collect real user data.
Where should I encode to prevent stored XSS?
Encode where you output, in every view that shows the value. Store the data raw. If you encode on input, you break other uses of the data, and you still need output encoding for each context. Use your template engine's default escaping and sanitize only the HTML you must allow.
How do I allow rich text safely?
Parse the user's HTML with a maintained sanitizer, allow a short list of tags and attributes, and run it at write time or render time. Do not write your own regular expression filter. Keep the sanitizer updated, because mutation XSS bugs such as CVE-2020-26870 in DOMPurify did occur.
Sources
- OWASP WSTG: Testing for Stored Cross Site Scripting (WSTG-INPV-02) (checked 9 November 2026)
- OWASP Community: Cross Site Scripting (XSS) (checked 9 November 2026)
- OWASP Cheat Sheet Series: Cross Site Scripting Prevention (checked 9 November 2026)
- PortSwigger Web Security Academy: Stored XSS (checked 9 November 2026)
- CWE-79: Improper Neutralization of Input During Web Page Generation (checked 9 November 2026)
- Django docs: Automatic HTML escaping (checked 9 November 2026)
- Laravel docs: Blade templates (checked 9 November 2026)
- DOMPurify README (checked 9 November 2026)
- MDN: Node.textContent (checked 9 November 2026)
- MDN: Content Security Policy (CSP) (checked 9 November 2026)
- Samy Kamkar: The MySpace worm (checked 9 November 2026)
- NVD: CVE-2021-44217 (Ericsson CodeChecker) (checked 9 November 2026)
- NVD: CVE-2019-9787 (WordPress before 5.1.1) (checked 9 November 2026)
- NVD: CVE-2020-26870 (DOMPurify before 2.0.17) (checked 9 November 2026)