How does cross-site scripting work?
Cross-site scripting happens when input from one user ends up in HTML or JavaScript that a browser executes. Because the script arrives from the application's own origin, the browser gives it everything that origin has: the DOM, same-origin API calls with the user's cookies, and any tokens stored in localStorage.
A search page that echoes the query into HTML without encoding it:
GET /search?q=%3Cimg%20src%3Dx%20onerror%3Dalert(1)%3E HTTP/1.1
Host: app.example
Cookie: session=user_aHTTP/1.1 200 OK
Content-Type: text/html; charset=utf-8
<p>No results for <img src=x onerror=alert(1)></p>The browser parses the reflected img tag, the image fails to load, and the onerror handler runs. An alert(1) proves execution and nothing more; a real attacker would replace it with script that acts as the victim. Correctly encoded output would read <img src=x ...> and render as harmless text.
What does XSS look like in practice?
The three classic types differ in where the payload lives and who has to deliver it.
- Reflected. The payload is in the request, usually a URL, and the server echoes it into the response. The attacker has to get the victim to open a crafted link.
- Stored. The payload is saved (a comment, a project name, a support ticket, an uploaded SVG) and served to everyone who views it. No link needed, and it often reaches admins through back-office tools that render customer data.
- DOM-based. The server never sees the payload. Client-side JavaScript reads from a source such as
location.hash,postMessage, ordocument.referrerand writes it into a sink such asinnerHTML,document.write,eval, or anhref. This is the dominant form in single-page apps.
A DOM-based example with a plain sink:
// Vulnerable: fragment text is written into the DOM as HTML
const tab = decodeURIComponent(location.hash.slice(1));
document.getElementById("title").innerHTML = `Viewing ${tab}`;Where frameworks stop protecting you
React, Vue, Angular, and server templates such as Jinja, Django templates, and ERB encode output by default. XSS in these stacks comes almost entirely from the escape hatches that switch encoding off, plus a few contexts encoding does not cover:
| Framework | Escape hatch | Typical misuse |
|---|---|---|
| React | dangerouslySetInnerHTML | Rendering CMS or Markdown HTML unsanitized |
| Vue | v-html | Rich-text fields, comment bodies |
| Angular | bypassSecurityTrustHtml | Silencing the sanitizer to make a widget render |
| Jinja, Django | the safe filter, mark_safe | Marking user-derived strings as trusted |
| Rails ERB | raw, html_safe | Helpers that build HTML with interpolation |
| Any | href and src attributes | A user-supplied javascript: URL |
| Any | inline script blocks | Serializing state into a script tag without JSON escaping |
Recent React versions replace a javascript: URL in href with a stub that throws; older versions only logged a warning, and most other frameworks do not intercept it. Validate URL schemes (https: and mailto: only, for example) wherever users supply links.
Sanitizing before a DOM method is not automatically safe either. CVE-2020-11022 in jQuery 1.12.0 through 3.4.x is the textbook case: NVD describes how passing HTML from untrusted sources to methods like .html() and .append(), even after sanitizing it, could still execute code. It was fixed in jQuery 3.5.0, and old copies remain common in legacy admin panels.
How do you test for XSS?
The goal is to prove script execution in a real browser, in the exact context where input lands.
- Inventory every reflection point. Submit a unique marker such as
xss4821into every parameter, header, and stored field, then search responses and the rendered DOM for it. Include places the input reappears later: emails, admin views, exports, notifications. - Identify the context. For each hit, note whether it lands in HTML text, an attribute, a URL, a script block, or a CSS value. Each context needs a different breakout.
- Probe the encoding. Send the characters that matter for that context:
<>for HTML, a quote for attributes,</script>for script blocks,javascript:for URLs. Check which come back unencoded. - Trace DOM sources to sinks. In an SPA, read the bundled JavaScript for sinks (
innerHTML,outerHTML,insertAdjacentHTML,eval,setTimeoutwith strings, framework escape hatches) and work back to whether any source controls them. Browser tooling such as DOM Invader automates part of this. - Confirm with a benign proof. A harmless
alert(1)orconsole.logshowingdocument.domainis the standard evidence. Record the browser and version, since parsing quirks and CSP enforcement vary. - Check what CSP would have stopped. Note whether the site's policy blocked the proof, which affects severity but does not remove the finding.
Dynamic scanners find many reflected cases. Stored XSS that only renders in an internal tool, and DOM XSS behind client-side routing, usually need manual testing or code review of the escape-hatch call sites.
How do you fix XSS?
Encode output for its context and keep framework auto-escaping on. When you have to render user-influenced HTML, sanitize it with a maintained HTML sanitizer at the point of rendering.
React, with the fix for the rich-text case:
import DOMPurify from "dompurify";
// Safe by default: React encodes this as text
export function Title({ name }: { name: string }) {
return <h1>{name}</h1>;
}
// Only when HTML is required: sanitize at the render site
export function Body({ html }: { html: string }) {
return <div dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(html) }} />;
}For the DOM example above, the fix is the text API: element.textContent = ... never parses HTML. In Django or Jinja, delete the safe filter and let the template encode; if HTML is genuinely needed, sanitize it on the server with an allowlist of tags.
Then add defense in depth, which limits damage when an escape hatch slips through:
- Content Security Policy. A nonce-based policy such as
script-src 'nonce-r4nd0m' 'strict-dynamic'; object-src 'none'; base-uri 'none'blocks inline handlers likeonerrorand scripts without the nonce. A policy that allows'unsafe-inline'provides little XSS protection. - Trusted Types. The
require-trusted-types-for 'script'CSP directive makes DOM sinks such asinnerHTMLreject plain strings, so every write has to pass through a named policy you can review. Per MDN's compatibility data it shipped in Chrome 83, Safari 26, and Firefox 148. - HttpOnly cookies. Script cannot read them, which protects the session cookie itself. It does not stop injected script from making requests as the user.
What does not work: a WAF or input filter that strips <script>. There are many other tags, event handlers, and encodings, and DOM-based payloads in a URL fragment never reach the server at all.
XSS vs CSRF
XSS runs the attacker's code inside the site's origin, so it can read responses and defeats most CSRF defenses, including tokens, by reading them from the page. CSRF only makes the victim's browser send a request from another site and cannot see the response. A site with XSS has, in effect, no CSRF protection.
[ Sources ]
Written by Parameter · Last reviewed

