Parameter

Cross-site scripting (XSS)

Cross-site scripting (XSS) is a vulnerability where an application places attacker-supplied input into a page without encoding it, so the victim's browser runs the attacker's JavaScript with the site's origin and can read the page, act as the logged-in user, or change what they see.

OWASP
A03:2021 Injection
Last reviewed

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_a
HTTP/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 &lt;img src=x ...&gt; 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, or document.referrer and writes it into a sink such as innerHTML, document.write, eval, or an href. 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:

FrameworkEscape hatchTypical misuse
ReactdangerouslySetInnerHTMLRendering CMS or Markdown HTML unsanitized
Vuev-htmlRich-text fields, comment bodies
AngularbypassSecurityTrustHtmlSilencing the sanitizer to make a widget render
Jinja, Djangothe safe filter, mark_safeMarking user-derived strings as trusted
Rails ERBraw, html_safeHelpers that build HTML with interpolation
Anyhref and src attributesA user-supplied javascript: URL
Anyinline script blocksSerializing 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.

  1. Inventory every reflection point. Submit a unique marker such as xss4821 into 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.
  2. 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.
  3. 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.
  4. Trace DOM sources to sinks. In an SPA, read the bundled JavaScript for sinks (innerHTML, outerHTML, insertAdjacentHTML, eval, setTimeout with strings, framework escape hatches) and work back to whether any source controls them. Browser tooling such as DOM Invader automates part of this.
  5. Confirm with a benign proof. A harmless alert(1) or console.log showing document.domain is the standard evidence. Record the browser and version, since parsing quirks and CSP enforcement vary.
  6. 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 like onerror and 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 as innerHTML reject 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.

Written by Parameter · Last reviewed

[ related terms ]

Related terms.

Cross-site request forgery (CSRF)

Cross-site request forgery (CSRF) is a vulnerability where a site the attacker controls makes a logged-in victim's browser send a state-changing request to another application, which accepts it because the browser attaches the victim's cookies automatically.

SQL injection (SQLi)

SQL injection (SQLi) is a vulnerability where user input is concatenated into a database query, letting an attacker change the query's logic to read rows they should not see, bypass login checks, modify or delete data, and sometimes run commands on the database server.

OWASP Top 10

The OWASP Top 10 is the Open Worldwide Application Security Project's ranked list of the ten most critical web application security risk categories, such as broken access control and injection.

Dynamic application security testing (DAST)

Dynamic application security testing (DAST) is automated testing of a running web application or API from the outside: a scanner crawls the app, sends modified requests to each input, and flags responses that show injection, cross-site scripting, misconfiguration or exposed data, without access to source code.

Secure code review

Secure code review is the examination of source code, usually a pull request diff, specifically to find security flaws such as missing authorization checks, injection, unsafe deserialization and leaked secrets, by a person, a static analysis tool, an AI reviewer, or a combination, before the change reaches production.