Cross-site scripting (XSS)
In web application security, cross-site scripting (XSS) is a flaw that lets an attacker run their own JavaScript in another user’s browser, inside the trust boundary of your site. The browser cannot tell the injected script from yours, so the script inherits that user’s session, their cookies and everything the application lets them do.
How it works
Cross-site scripting happens when a value that came from outside the application ends up in the page as code instead of as content. The browser has no way to know that a script tag arrived from a query parameter rather than from the developer, so it runs it with the full authority of the page: same origin, same cookies, same access to the DOM and to every API the logged-in user is entitled to call.
The mechanism is always three steps. An untrusted value enters the application, through a URL parameter, a form field, an HTTP header, a database record or a message from another system. It travels to a place in the page where the browser will parse it. And it is written there without being encoded for that specific place.
That last step is where most implementations fail, because encoding is not one thing. A value that is safe inside an HTML text node is not safe inside an attribute, inside a script block, inside a URL or inside a CSS declaration. Each of those is a separate parsing context with a separate escape, and a single escaping helper applied everywhere will be wrong in at least one of them.
What the attacker gets is not a popup box. Once the script runs it is the user. It reads the DOM, submits forms on their behalf, calls the APIs the session can reach and, if the session cookie is not marked HttpOnly, reads the cookie directly. Where the application offers a self-service email or password change, the distance between XSS and account takeover is one request.
What goes wrong
The recurring failure we find is not an application with no encoding. It is an application with encoding that was chosen for the wrong context. A framework escapes HTML correctly everywhere it renders text, and then one template drops a value into an event handler attribute or builds an element with innerHTML, and that one place decides the finding.
DOM-based XSS is the variant that survives longest, because it never appears in the server response. The payload can sit in the fragment after the hash, which browsers do not send to the server at all, so nothing in the access logs or in a server-side filter ever sees it. We look for it by reading the client bundle for the sinks, not by fuzzing the server.
Stored XSS is the one that changes the shape of an engagement. A reflected payload needs the victim to follow a link. A stored payload waits in a support ticket, a device name, a profile field or an audit log until a privileged user opens the page that renders it. On internal applications the reliable route to an administrator is rarely the login form: it is a field the administrator is required to read as part of their job. We aim payloads at back office views for exactly that reason, and the finding we write is not “there is XSS in the ticket subject” but “a standard user can execute script in the console of whoever reviews the queue”.
Reflected, stored and DOM-based XSS
The three names describe where the payload lives, not what the script can do. Once it runs, the capability is the same. What changes is who can be reached, and what evidence exists afterwards.
| Reflected | Stored | DOM-based | |
|---|---|---|---|
| Where the payload lives | In the request, echoed straight back | In the application’s own storage | In client-side code only |
| What it takes to fire | The victim follows a crafted link | The victim opens a normal page | The victim opens a crafted URL or state |
| Who it reaches | One target at a time | Everyone who views the record | One target at a time |
| Visible in server logs | Yes | Yes, at write time | Often not, if it rides in the fragment |
| Where it is fixed | Output encoding on the server response | Encoding at render, not sanitising at input | The client sink, in JavaScript |
The practical consequence is about severity, not taxonomy. Stored XSS in a view that only administrators open is worth more than reflected XSS anywhere, because it needs no social engineering and it targets exactly the account an attacker wants.
Common mistakes
Sanitising on input instead of encoding on output. The safe representation depends on where the value is rendered, and that is not known at input time. Cleaning at the front door also corrupts legitimate data and gives a false sense of coverage.
Treating a web application firewall as the fix. Request filters raise the cost of a payload and lower the cost of nothing else. They are worth having as a delay, and they are not a control you can point an auditor at when the sink is still there.
Assuming HttpOnly closes it. The flag stops the script reading the cookie. It does not stop the script using the cookie: requests from the page still carry it. HttpOnly downgrades cookie theft to session hijacking performed inside the victim’s browser, which is usually enough for the attacker.
Writing a bespoke sanitiser. HTML parsing is full of edge cases that browsers resolve differently, and mutation issues turn text that looked inert into markup after the DOM re-serialises it. Use a maintained library or do not accept HTML.
Shipping a Content Security Policy with unsafe-inline. That single keyword removes most of what the policy was added to do, and it is the most common state we find one in.
How to reduce it
Render through a templating engine that encodes by default and treat every place you bypass it as a decision that needs a reason. In the client, prefer textContent to innerHTML, and where markup genuinely has to be built, put it behind a sanitiser and behind Trusted Types so the browser refuses raw strings at the sink.
Then add a Content Security Policy as a second layer, built on nonces or hashes rather than an allow-list of hosts, and without unsafe-inline. A policy in report-only mode for a release or two tells you what would break before it breaks. Mark session cookies HttpOnly, Secure and SameSite, which is the same set of cookie security attributes that limits what a successful payload can carry away.
For detection, an automated DAST scan finds reflected cases cheaply and finds almost no DOM-based ones, because the sink is in code it does not read. Review the client bundle for the sinks directly, and log CSP violation reports: in a policy that has been stable for a while, a sudden burst of inline script violations on one route is a real signal.
Where this shows up in an audit
In a web application report, XSS is written per sink and per context, never once per application. The reproduction we hand over is the exact request or the exact client state, the field, the rendered context that made the payload execute, and a screenshot of the consequence rather than of an alert box. Where the payload reaches a privileged view, we say which role and which screen, because that is what moves the severity.
Severity is decided by reach and by persistence, not by the class. A reflected case behind a login that only affects the person who clicks is an ordinary finding. A stored case that a support agent triggers by doing their job, in a session with elevated rights, is the finding the whole report is built around.
This is one of the things we look for when we test a web application by hand.
FAQ
Is XSS still a real risk in 2026? Yes. Modern frameworks encode by default, which removed the easy cases, and moved the remaining ones into client-side code, third-party widgets and the places where developers deliberately opt out of escaping. We still find it in most applications with a rich back office.
Does a Content Security Policy stop XSS? It contains it rather than stops it. A strict nonce-based policy blocks most injected script from executing, which turns a critical finding into a lower one, but the injection point is still there and policies drift. It is a second layer, not the fix.
What is the difference between XSS and CSRF? XSS runs attacker code inside your origin. Cross-site request forgery runs no code at all: it makes the victim’s browser send a request the attacker cannot read the answer to. XSS is strictly more powerful, and it defeats CSRF tokens because it can read them.
Why did our scanner not find it? Scanners test the server response. DOM-based cases never reach the server, stored cases need the scanner to reach the page that renders the record, and both need a valid privileged session. A clean scan is evidence about reflected cases only.