CSRF Prevention — Tokens, SameSite Cookies, and the Patterns That Actually Work

Cross-Site Request Forgery remains a top web vulnerability despite years of awareness. This guide covers the attack mechanics, why naive defences fail, and the complete modern prevention stack including CSRF tokens, SameSite cookies, and custom request headers.

How CSRF Works

Cross-Site Request Forgery exploits the browser’s automatic inclusion of cookies in cross-origin requests. When a user is authenticated to bank.com and visits a malicious page on evil.com, any request that evil.com causes the browser to make to bank.com will automatically carry the victim’s bank.com session cookie.

A minimal CSRF attack:

<!-- On evil.com -->
<img src="https://bank.com/transfer?to=attacker&amount=10000" width="0" height="0">

The browser fetches the URL, sends the victim’s session cookie, and the transfer executes — no JavaScript required.

For POST requests:

<form id="csrf" action="https://bank.com/transfer" method="POST">
  <input name="to" value="attacker">
  <input name="amount" value="10000">
</form>
<script>document.getElementById('csrf').submit();</script>

The attack works because the server cannot distinguish between a legitimate form submission and one forged by a third-party site — both carry the same session cookie.

Why Checking the Referer Header Fails

A common but insufficient defence: check that the Referer header matches your domain. Problems:

  • Referer is not sent in all browsers and configurations (HTTPS→HTTP transitions, privacy settings)
  • Referer can be stripped by intermediaries
  • The referrer-policy header on the attacker’s page can suppress or control the Referer value

Never rely on Referer alone.

The Three Defences That Work

1. Synchronizer Token Pattern (CSRF Tokens)

Generate a random, unpredictable token server-side, include it in every state-changing form as a hidden field, and validate it on submission. Since evil.com cannot read your server’s responses (same-origin policy), it cannot obtain the token to include in a forged request.

Django (Python) — built-in:

# settings.py — CsrfViewMiddleware is included by default
MIDDLEWARE = [
    ...
    'django.middleware.csrf.CsrfViewMiddleware',
    ...
]
<!-- Template -->
<form method="POST">
  {% csrf_token %}
  <input type="text" name="amount">
  <button type="submit">Transfer</button>
</form>

For AJAX:

// Get the token from the 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: {
    'X-CSRFToken': getCookie('csrftoken'),
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({ to: 'friend', amount: 50 }),
});

Express.js (Node) with csurf:

const csrf = require('csurf');
const csrfProtection = csrf({ cookie: true });

app.get('/transfer', csrfProtection, (req, res) => {
  res.render('transfer', { csrfToken: req.csrfToken() });
});

app.post('/transfer', csrfProtection, (req, res) => {
  // If CSRF token is invalid, csurf throws an error
  processTransfer(req.body);
  res.redirect('/success');
});

Spring Boot (Java):

// Spring Security includes CSRF protection by default for stateful apps
// For REST APIs with JWT, you typically disable it and use stateless auth instead:
@Configuration
public class SecurityConfig {
    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            .csrf(csrf -> csrf
                .csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())
            );
        return http.build();
    }
}

The SameSite attribute on session cookies directly prevents CSRF by instructing the browser not to send the cookie on cross-site requests.

# Django settings.py
SESSION_COOKIE_SAMESITE = 'Lax'   # or 'Strict'
CSRF_COOKIE_SAMESITE = 'Lax'
// Express.js
app.use(session({
  secret: process.env.SESSION_SECRET,
  cookie: {
    sameSite: 'lax',
    secure: true,
    httpOnly: true,
  }
}));

Strict vs Lax — which to use:

  • Strict — cookie never sent on cross-site requests, including top-level navigations (clicking a link from another site logs the user out). Best security, but breaks login flows from external links.
  • Lax — cookie sent on top-level navigations (GET requests from links) but not on cross-origin subresource requests or form POSTs. The recommended default — prevents CSRF attacks while maintaining normal navigation.
  • None — always sent cross-site (requires Secure). Use only for third-party cookie scenarios (embeds, OAuth redirects).

Important caveat: SameSite=Lax does not protect against CSRF if your application performs state changes via GET requests. Ensure all state-changing operations use POST/PUT/DELETE.

3. Custom Request Header Validation

For API endpoints that receive JSON via fetch() or XMLHttpRequest, requiring a custom header (e.g., X-Requested-With: XMLHttpRequest) is a lightweight defence. Cross-origin requests cannot set arbitrary custom headers without a CORS preflight, and the preflight would be blocked by your CORS policy.

// Client
fetch('/api/transfer', {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
    'X-Requested-With': 'XMLHttpRequest',
  },
  body: JSON.stringify(data),
});
# Server-side validation (Flask example)
@app.route('/api/transfer', methods=['POST'])
def transfer():
    if request.headers.get('X-Requested-With') != 'XMLHttpRequest':
        abort(403)
    # process request

This is a defence-in-depth measure, not a standalone protection — pair it with SameSite cookies.

What About Single-Page Applications?

SPAs that use JWTs stored in memory (not cookies) are naturally immune to CSRF — there’s no cookie for the browser to automatically attach. However:

  • If you store the JWT in localStorage, XSS can steal it (different threat model)
  • If you use httpOnly cookies for the JWT refresh token, CSRF is relevant again for the refresh endpoint

The recommended pattern for SPAs: store access tokens in memory, use httpOnly + SameSite=Strict cookies for refresh tokens, and validate the Origin or Referer header on the token refresh endpoint.

Testing for CSRF

Quick manual test with Burp Suite:

  1. Capture a state-changing request
  2. Right-click → “Generate CSRF PoC”
  3. Open the PoC in a browser while authenticated to the target — if the request succeeds, CSRF is present

Automated scanning: OWASP ZAP’s active scanner includes CSRF detection. Run against your staging environment as part of CI.