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.
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.
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.
# 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.
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 class | Confirming artifact | Scanner requirement |
|---|---|---|
| Reflected | Injected value in the HTTP response body of the same session | HTTP response inspection - one request |
| Stored | Injected marker in a second, unrelated session's HTTP response | Two independent sessions with separate auth contexts |
| DOM-based | Call-graph trace from URL/storage source to a dangerous JS sink | Inter-procedural JS taint analysis or headless browser observation |
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 context | Example | Execution | Severity |
|---|---|---|---|
| Script block body | <script>...INJECT...</script> | Full JS in victim browser | Critical |
| Event handler attribute | onerror='INJECT' | JS execution on trigger event | Critical |
| javascript: URI | <a href='javascript:INJECT'> | JS execution on navigation | Critical |
| HTML element / text node | <div>INJECT</div> | Rendered HTML - no script | High |
| Non-executable attribute | <div class='INJECT'> | Attribute mutation only | Medium |
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.
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.