Cross-Site Request Forgery (CSRF): Token-Based Protection

Cross-Site Request Forgery is one of those vulnerabilities that sounds abstract until you see it in action. The attack is deceptively simple: a malicious website tricks your browser into sending a...

Key Insights

  • CSRF attacks exploit the browser’s automatic cookie inclusion to trick authenticated users into performing unintended actions—protection requires validating that requests originate from your application, not a malicious third party.
  • The synchronizer token pattern remains the gold standard: generate a cryptographically random token per session, embed it in forms or headers, and reject any request that doesn’t include a valid token.
  • Single-page applications require adapted strategies like custom headers or double-submit cookies, since traditional form-based tokens don’t fit the AJAX request model.

What is CSRF and Why It Matters

Cross-Site Request Forgery is one of those vulnerabilities that sounds abstract until you see it in action. The attack is deceptively simple: a malicious website tricks your browser into sending a request to a site where you’re already authenticated. Because browsers automatically include cookies with every request to a domain, the target server can’t distinguish between a legitimate action and a forged one.

The impact is severe. In 2008, attackers used CSRF to transfer funds from users’ bank accounts. Netflix fell victim to CSRF that let attackers change account settings. Even today, CSRF vulnerabilities appear regularly in bug bounty reports because developers underestimate the threat or implement protection incorrectly.

Any state-changing operation is a potential target: password changes, email updates, fund transfers, admin actions, or data deletion. If your application accepts authenticated requests that modify data, CSRF protection isn’t optional.

Anatomy of a CSRF Attack

Understanding the attack mechanics is essential for building effective defenses. Here’s how a typical CSRF attack unfolds:

  1. The victim logs into their banking application, receiving a session cookie
  2. Without logging out, they visit a malicious website (perhaps through a phishing link)
  3. The malicious site contains code that automatically submits a request to the bank
  4. The browser includes the session cookie, and the bank processes the transfer

Here’s what that malicious page might look like:

<!DOCTYPE html>
<html>
<body onload="document.getElementById('csrf-form').submit();">
  <h1>Congratulations! You've won a prize!</h1>
  
  <form id="csrf-form" action="https://bank.example.com/transfer" method="POST" style="display:none;">
    <input type="hidden" name="recipient" value="attacker-account-12345" />
    <input type="hidden" name="amount" value="10000" />
    <input type="hidden" name="currency" value="USD" />
  </form>
</body>
</html>

The victim sees a fake prize notification while the hidden form silently submits. The same-origin policy prevents the malicious site from reading the response, but the damage is done—the request executed with full authentication.

This works because cookies are sent automatically based on the destination domain, not the origin of the request. The browser doesn’t care that the form submission originated from evil-site.com; it sees a request to bank.example.com and dutifully attaches the session cookie.

How Token-Based Protection Works

The synchronizer token pattern solves CSRF by requiring something the attacker can’t obtain: a secret value that proves the request originated from your application.

The flow works like this:

  1. When a user’s session begins, the server generates a cryptographically random token
  2. This token is stored server-side (typically in the session) and embedded in every form
  3. When the form submits, the server compares the submitted token against the stored value
  4. Requests with missing or mismatched tokens are rejected

The key insight is that while an attacker can forge a form submission, they cannot read the token value from your pages due to same-origin policy restrictions. They’re forced to guess, and with sufficient entropy, guessing is computationally infeasible.

Here’s secure token generation in Node.js:

const crypto = require('crypto');

function generateCSRFToken() {
  // 32 bytes = 256 bits of entropy, rendered as 64 hex characters
  return crypto.randomBytes(32).toString('hex');
}

function initializeSession(session) {
  if (!session.csrfToken) {
    session.csrfToken = generateCSRFToken();
  }
  return session.csrfToken;
}

Never use predictable values like timestamps, user IDs, or sequential numbers. Attackers can guess or enumerate these. Use your language’s cryptographically secure random number generator—crypto.randomBytes in Node.js, secrets.token_hex in Python, or SecureRandom in Java.

Implementing CSRF Tokens in Forms

For traditional server-rendered applications, implementation is straightforward. Here’s a complete Express.js example:

const express = require('express');
const session = require('express-session');
const crypto = require('crypto');

const app = express();

app.use(express.urlencoded({ extended: true }));
app.use(session({
  secret: process.env.SESSION_SECRET,
  resave: false,
  saveUninitialized: true,
  cookie: { secure: true, httpOnly: true, sameSite: 'strict' }
}));

// CSRF middleware
function csrfProtection(req, res, next) {
  // Generate token if not exists
  if (!req.session.csrfToken) {
    req.session.csrfToken = crypto.randomBytes(32).toString('hex');
  }
  
  // Make token available to views
  res.locals.csrfToken = req.session.csrfToken;
  
  // Skip validation for safe methods
  if (['GET', 'HEAD', 'OPTIONS'].includes(req.method)) {
    return next();
  }
  
  // Validate token for state-changing requests
  const submittedToken = req.body._csrf || req.headers['x-csrf-token'];
  
  if (!submittedToken || submittedToken !== req.session.csrfToken) {
    return res.status(403).json({ error: 'Invalid CSRF token' });
  }
  
  next();
}

app.use(csrfProtection);

app.get('/transfer', (req, res) => {
  res.send(`
    <form method="POST" action="/transfer">
      <input type="hidden" name="_csrf" value="${res.locals.csrfToken}" />
      <input type="text" name="recipient" placeholder="Recipient" />
      <input type="number" name="amount" placeholder="Amount" />
      <button type="submit">Transfer</button>
    </form>
  `);
});

app.post('/transfer', (req, res) => {
  // Token already validated by middleware
  res.json({ success: true, message: 'Transfer processed' });
});

The hidden _csrf field travels with the form submission. An attacker crafting a malicious form cannot include this value because they cannot read it from your page.

CSRF Protection for AJAX/SPA Applications

Single-page applications complicate matters because they don’t submit traditional forms. Two patterns work well here: custom headers and double-submit cookies.

Custom Header Approach

Browsers enforce that custom headers cannot be set on cross-origin requests without CORS approval. By requiring a custom header containing the CSRF token, you ensure the request originated from JavaScript running on your domain:

// Frontend: Axios interceptor
import axios from 'axios';

// Get token from meta tag or cookie
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content;

axios.defaults.headers.common['X-CSRF-Token'] = csrfToken;

// Or use an interceptor for more control
axios.interceptors.request.use(config => {
  if (['post', 'put', 'patch', 'delete'].includes(config.method)) {
    config.headers['X-CSRF-Token'] = csrfToken;
  }
  return config;
});

Double-Submit Cookie Pattern

This approach avoids server-side token storage. The server sets a CSRF token in a cookie, and the client must read this cookie and submit it as a header or form field:

# Django REST Framework setup
# settings.py
CSRF_COOKIE_HTTPONLY = False  # JavaScript needs to read it
CSRF_COOKIE_SAMESITE = 'Strict'
CSRF_TRUSTED_ORIGINS = ['https://yourdomain.com']

REST_FRAMEWORK = {
    'DEFAULT_AUTHENTICATION_CLASSES': [
        'rest_framework.authentication.SessionAuthentication',
    ],
}
// Frontend: Reading Django's CSRF cookie
function getCookie(name) {
  const value = `; ${document.cookie}`;
  const parts = value.split(`; ${name}=`);
  if (parts.length === 2) return parts.pop().split(';').shift();
}

fetch('/api/transfer', {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
    'X-CSRFToken': getCookie('csrftoken')
  },
  body: JSON.stringify({ recipient: 'account-123', amount: 500 })
});

The double-submit pattern works because an attacker cannot read cookies from your domain, so they cannot include the matching header value.

Common Pitfalls and Bypasses

Even with CSRF protection in place, implementation flaws create vulnerabilities:

Token Leakage via Referer Headers

If your application logs URLs or sends them to third-party analytics, tokens in query strings can leak. Never put CSRF tokens in URLs—use POST bodies or headers.

GET Request Vulnerabilities

State-changing GET requests bypass most CSRF protection. An <img src="https://bank.com/transfer?to=attacker&amount=1000"> tag triggers a GET request automatically. Ensure all state-changing operations require POST, PUT, PATCH, or DELETE.

Subdomain Issues

If evil.subdomain.yoursite.com is compromised, attackers may be able to set cookies for the parent domain, potentially overwriting CSRF tokens. Use the __Host- cookie prefix to prevent this:

res.cookie('__Host-csrf', token, {
  secure: true,
  httpOnly: true,
  sameSite: 'strict',
  path: '/'
});

Token Fixation

Regenerate CSRF tokens after authentication. If an attacker can set a known token before the victim logs in, they can use that token for forged requests afterward.

Testing and Validation

Verification is critical. Here’s a simple test script that should fail against properly protected endpoints:

import requests

# Attempt CSRF without valid token
target_url = 'http://localhost:3000/transfer'

# First, get a session (simulating logged-in user)
session = requests.Session()
session.get('http://localhost:3000/login')  # Assume this sets session cookie

# Attempt state-changing request without CSRF token
response = session.post(target_url, data={
    'recipient': 'attacker-account',
    'amount': '10000'
    # Note: no _csrf field
})

if response.status_code == 403:
    print('✓ CSRF protection working: request rejected')
else:
    print(f'✗ VULNERABILITY: request succeeded with status {response.status_code}')

# Attempt with invalid token
response = session.post(target_url, data={
    'recipient': 'attacker-account',
    'amount': '10000',
    '_csrf': 'invalid-token-value'
})

if response.status_code == 403:
    print('✓ Invalid token rejected correctly')
else:
    print(f'✗ VULNERABILITY: invalid token accepted')

For automated testing, tools like OWASP ZAP and Burp Suite can detect missing CSRF protection. Include CSRF tests in your CI pipeline—they’re fast and catch regressions early.

Manual testing should verify: tokens are present in all forms, tokens are validated on submission, tokens are regenerated after login, and GET requests cannot perform state changes.

CSRF protection is foundational security. Get it right once, enforce it consistently, and you eliminate an entire class of attacks.

Liked this? There's more.

Every week: one practical technique, explained simply, with code you can use immediately.