AppSec · Aug 13, 2026 · 7 min read

Stored XSS: confirming the execution sink without running a payload

A stored value that appears in another session's DOM is not the same finding as one that executes JavaScript in a victim browser. The execution sink decides severity - a benign marker oracle confirms the stored path without triggering a real script.

Stored XSS is confirmed when a value injected by one session appears as executable content in a second, independent session. Reflection alone - the injected value present in the HTML - is the first step. Execution in a victim browser, in an unrelated session, is the finding. A probe that conflates the two over-claims; one that only confirms storage under-claims. Severity is decided by the sink type, not by whether any byte looks like JavaScript.

01Reflection is reachability, not confirmation

Reflected XSS returns the injected value in the same HTTP response - probe and confirm in one request. Stored XSS is different: the injected value is persisted server-side and later served to a different session that never sent the original input. That gap between injection and execution is what makes stored XSS higher-impact and harder to confirm safely. Seeing the value echoed in the POST response is not confirmation - it is the starting signal.

Session A ──► POST /api/comments body=<img data-ap='62615533' src='x'> 200 OK (stored) Session B ──► GET /posts/1 (separate auth token, clean cookies) ──► response contains data-ap='62615533' ? yes ──► stored path confirmed classify sink ──► severity
Confirming stored XSS requires two independent sessions. The storage probe and the retrieval check are separate steps with separate auth contexts.

02The benign oracle: a marker that stores, not executes

The confirming payload is not a real XSS payload. It is a uniquely identifiable, non-executing HTML element chosen so its presence in a second session's response proves the stored path - and nothing else. An <img> with a nonce data attribute satisfies both constraints: it is a valid HTML element that a browser renders without executing anything, and its presence is unambiguous because the nonce is globally unique to this probe.

stored-xss-probe.pypython
# Step 1: inject a non-executing marker in session A
nonce    = "62615533"
marker   = f"<img data-ap='{nonce}' src='x'>"  # no onerror - purely structural
resp_a   = session_a.post("/api/comments", json={"body": marker})
assert resp_a.status_code == 200

# Step 2: retrieve in session B - different auth context, clean cookies
resp_b   = session_b.get("/posts/1")
if f"data-ap='{nonce}'" in resp_b.text:
    sink = classify_sink(resp_b.text, nonce)
    # script | event_handler | js_uri | html_text | attr
    raise_finding(sink=sink, evidence=resp_b, nonce=nonce)
# No onerror, no real payload. Proof is the marker's presence, not execution.

The src='x' attribute causes a browser to attempt loading a non-existent resource - a benign network event that produces no harm and no side channel the probe relies on. The confirming artifact is the nonce attribute appearing in session B's response, not any triggered callback.

Proven - HighStored HTML injection - comment body - /posts/1
Session A: POST /api/comments {"body":"<img data-ap='62615533' src='x'>"} ──► 200 OK (value persisted) Session B: GET /posts/1 (separate auth context, new cookie jar) ──► 200 OK body contains: <img data-ap='62615533' src='x'> Sink: HTML element node in page body - no event handler, no script context
Oracle: cross-session marker retrieval. No script executed during probe. Rated high - stored HTML injection confirmed. Upgrade to critical if event handler or script-context injection is confirmed on a follow-up targeted probe.

03DOM-based XSS: when the server response is clean

DOM-based XSS never appears in the server's response. Client-side JavaScript reads a taint source - URL fragment, query parameter, localStorage, postMessage - and writes it to a dangerous sink such as innerHTML, eval, document.write or location.href. Because the injected value is consumed entirely in the browser, a response-based scanner that looks only at HTTP replies reports a clean result for every DOM-based path it touches. Confirming DOM XSS requires either static inter-procedural taint analysis through the JavaScript call graph or a headless browser environment that can observe DOM mutations after page execution.

The two classes call for different evidence artifacts. A server-reflected or stored finding carries the HTTP response body as the confirming artifact: the marker is visible in the raw HTML. A DOM-based finding carries the call-graph trace from taint source to sink function, plus the DOM state observed by the headless session after the page ran - because the server response alone is not the proof.

XSS classConfirming artifactScanner requirement
ReflectedInjected value in the HTTP response body of the same sessionHTTP response inspection - one request
StoredInjected marker in a second, unrelated session's HTTP responseTwo independent sessions with separate auth contexts
DOM-basedCall-graph trace from URL/storage source to a dangerous JS sinkInter-procedural JS taint analysis or headless browser observation

04Severity follows the execution sink

The marker probe confirms that a value is stored and served to other sessions. Severity is then set by what the serving context actually permits. An injected value that lands in an HTML text node can render attacker-controlled content - a real finding, but not JavaScript execution. The same value landing inside a <script> block, an event handler attribute, or a javascript: URI gives the attacker arbitrary code execution in every subsequent visitor's browser - a categorically different impact.

Sink contextExampleExecutionSeverity
Script block body<script>...INJECT...</script>Full JS in victim browserCritical
Event handler attributeonerror='INJECT'JS execution on trigger eventCritical
javascript: URI<a href='javascript:INJECT'>JS execution on navigationCritical
HTML element / text node<div>INJECT</div>Rendered HTML - no scriptHigh
Non-executable attribute<div class='INJECT'>Attribute mutation onlyMedium

A finding rated critical on a script-context sink carries the nonce-based stored path as the primary artifact, plus a targeted follow-up that shows the sink can evaluate code - using an arithmetic oracle such as 7919*7907=62615533 embedded in a safe expression rather than a real payload. That second check is the difference between 'script context confirmed by sink classification' and 'arithmetic evaluated by the runtime', and it is what makes the critical rating defensible in an audit.

2
sessions required to confirm stored path
0
scripts executed during the probe
3
sink classes that map to critical severity

The discipline that applies to every other boundary test applies here too: the confirming artifact proves the boundary failed without weaponizing the proof. A stored marker in a second session's response is the fact. The sink classification is the context. The severity follows from both, not from the presence of angle brackets in the injected value.

Evidence over heuristicsReachable beats foundMonitor → Block: rolling out a gate without slowing teamsIaC-grounded threat modelingAir-gapped AppSec with no phone-homeOne platform vs three tools
See it on your own app →