Securityintermediate

CSRF (Cross-Site Request Forgery) — Understanding & Prevention

Learn how CSRF attacks exploit trusted sessions, how SameSite cookies and CSRF tokens defend against them, and when the frontend can or cannot protect you.

12 min read·Published Apr 16, 2026
securitycsrfcookiestokens

What Is CSRF?

Cross-Site Request Forgery (CSRF) is an attack that tricks a user's browser into making an unwanted request to a site where the user is already authenticated. The browser automatically includes cookies with every request to a domain — including session cookies. An attacker exploits this by crafting a request that performs an action on behalf of the victim without their knowledge.

The key insight: the browser sends cookies automatically. The attacker does not need to know the cookie value. They just need the victim's browser to make the request.

 Victim (authenticated on bank.com)       Attacker's Site (evil.com)
 -----------------------------------       --------------------------
    |                                          |
    |--- Visits evil.com (any reason) -------->|
    |                                          |
    |   evil.com serves hidden form:           |
    |   <form action="bank.com/transfer"       |
    |     method="POST">                       |
    |     <input name="to" value="attacker">   |
    |     <input name="amount" value="10000">  |
    |   </form>                                |
    |   <script>form.submit()</script>         |
    |                                          |
    |<-- Browser auto-submits form ------------|
    |    with bank.com session cookie          |
    |                                          |
    |--- POST bank.com/transfer -------------->|  bank.com
    |    Cookie: session=abc123                |  --------
    |    to=attacker&amount=10000              |    |
    |                                          |    |-- Sees valid session
    |                                          |    |-- Processes transfer
    |                                          |    |-- $10,000 sent to attacker

CSRF does not steal data. It performs state-changing actions — transferring funds, changing email addresses, modifying passwords, creating accounts, deleting records.

How CSRF Attacks Work

Attack Vector 1: Auto-Submitting Forms

The most classic CSRF attack uses a hidden HTML form that auto-submits:

<!-- Hosted on attacker's site: evil.com/attack.html -->
<html>
<body onload="document.getElementById('csrf-form').submit();">
  <form id="csrf-form" action="https://bank.com/api/transfer" method="POST">
    <input type="hidden" name="recipient" value="attacker-account" />
    <input type="hidden" name="amount" value="5000" />
  </form>
</body>
</html>

When the victim visits this page (via a phishing link, malvertising, or a compromised site), the form auto-submits. The browser includes the victim's bank.com cookies with the POST request.

Attack Vector 2: Image Tags (GET Requests)

If a server performs state changes on GET requests (a design flaw), a simple image tag triggers the attack:

<!-- Embedded anywhere — email, forum post, comment -->
<img src="https://bank.com/api/transfer?to=attacker&amount=5000" style="display:none" />

The browser makes a GET request to load the "image," including cookies. This is why state-changing operations must never use GET.

Attack Vector 3: Fetch/XHR with Credentials

// From evil.com — but this usually gets blocked by CORS
// (see limitations below)
fetch('https://bank.com/api/transfer', {
  method: 'POST',
  credentials: 'include',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ to: 'attacker', amount: 5000 }),
});

This approach is typically blocked by CORS (the browser sends a preflight OPTIONS request, and bank.com would not include evil.com in Access-Control-Allow-Origin). However, simple requests (form-encoded POST without custom headers) skip the preflight, which is why form-based CSRF works.

What Makes CSRF Possible

+----------------------------------+-------------------------------------------+
| Condition                        | Why It Enables CSRF                       |
+----------------------------------+-------------------------------------------+
| Cookie-based authentication      | Browser auto-attaches cookies             |
| No origin validation             | Server cannot distinguish legit/malicious |
| State changes on GET             | Trivially triggered via img/link tags     |
| Predictable request structure    | Attacker can construct valid requests     |
| No CSRF token                    | No proof request originated from our app  |
+----------------------------------+-------------------------------------------+

Prevention Strategy 1: SameSite Cookies

The SameSite cookie attribute controls whether the browser sends cookies with cross-site requests. This is the simplest and most effective CSRF defense.

+-------------------+-------------------------------------------+-------------------+
| SameSite Value    | Behavior                                  | CSRF Protection   |
+-------------------+-------------------------------------------+-------------------+
| Strict            | Cookie never sent cross-site              | Full              |
| Lax (default)     | Cookie sent with top-level GET navigations| Partial (POST     |
|                   | but not with POST, iframe, fetch          | protected)        |
| None              | Cookie always sent (requires Secure flag) | None              |
+-------------------+-------------------------------------------+-------------------+

Setting SameSite in Express:

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

const app = express();

app.use(session({
  name: 'sessionId',
  secret: process.env.SESSION_SECRET,
  cookie: {
    httpOnly: true,       // Not accessible via JavaScript
    secure: true,         // Only sent over HTTPS
    sameSite: 'strict',   // Never sent cross-site
    maxAge: 3600000,      // 1 hour
  },
}));

Setting cookies with SameSite manually:

// Express
res.cookie('session', token, {
  httpOnly: true,
  secure: true,
  sameSite: 'strict',
  path: '/',
  maxAge: 3600 * 1000,
});

// Next.js API route
export default function handler(req, res) {
  res.setHeader('Set-Cookie', [
    `session=${token}; HttpOnly; Secure; SameSite=Strict; Path=/; Max-Age=3600`,
  ]);
  res.json({ success: true });
}

SameSite=Strict vs Lax — When to use which:

  • Use Strict for sensitive cookies (session, auth tokens). Downside: if a user clicks a link from email to your site, they will not be logged in (the cookie is not sent on cross-site navigation).
  • Use Lax when you need users to stay logged in when arriving from external links. Lax still protects POST requests, which covers most CSRF attacks.
// Strategy: Use two cookies
res.cookie('session', token, {
  httpOnly: true,
  secure: true,
  sameSite: 'strict',   // For state-changing operations
});

res.cookie('session_lax', token, {
  httpOnly: true,
  secure: true,
  sameSite: 'lax',      // For read-only page loads
});

// Middleware: require strict cookie for POST/PUT/DELETE
function csrfProtection(req, res, next) {
  if (['POST', 'PUT', 'DELETE', 'PATCH'].includes(req.method)) {
    if (!req.cookies.session) {
      return res.status(403).json({ error: 'CSRF protection: re-authenticate' });
    }
  }
  next();
}

Prevention Strategy 2: CSRF Tokens

CSRF tokens are secret, unpredictable values tied to the user's session. The server generates the token, embeds it in forms or sends it in a response, and then validates it on subsequent requests. Since the attacker cannot read the token (same-origin policy prevents it), they cannot include it in their forged request.

Synchronizer Token Pattern

The server stores the CSRF token in the session and validates it on each request:

const crypto = require('crypto');

// Generate and store token in session
function generateCsrfToken(req) {
  const token = crypto.randomBytes(32).toString('hex');
  req.session.csrfToken = token;
  return token;
}

// Middleware to validate token
function validateCsrfToken(req, res, next) {
  if (['GET', 'HEAD', 'OPTIONS'].includes(req.method)) {
    return next(); // Safe methods don't need CSRF protection
  }

  const token = req.body._csrf
    || req.headers['x-csrf-token']
    || req.query._csrf;

  if (!token || token !== req.session.csrfToken) {
    return res.status(403).json({ error: 'Invalid CSRF token' });
  }

  next();
}

// Route to get token (for SPA frontends)
app.get('/api/csrf-token', (req, res) => {
  const token = generateCsrfToken(req);
  res.json({ csrfToken: token });
});

// Protected route
app.post('/api/transfer', validateCsrfToken, (req, res) => {
  // Process transfer — CSRF validated
});

Embedding the token in an HTML form:

<form action="/api/transfer" method="POST">
  <input type="hidden" name="_csrf" value="<%= csrfToken %>" />
  <input type="text" name="recipient" />
  <input type="number" name="amount" />
  <button type="submit">Transfer</button>
</form>

This pattern does not require server-side session state. The server sets a random value in a cookie and expects the same value in a request header or body. The attacker can make the browser send the cookie but cannot read it (same-origin policy), so they cannot duplicate the value in the header.

const crypto = require('crypto');

// Set CSRF cookie
app.use((req, res, next) => {
  if (!req.cookies.csrf) {
    const token = crypto.randomBytes(32).toString('hex');
    res.cookie('csrf', token, {
      httpOnly: false,      // Must be readable by JavaScript
      secure: true,
      sameSite: 'strict',
      path: '/',
    });
  }
  next();
});

// Validate: cookie value must match header value
function doubleSubmitValidation(req, res, next) {
  if (['GET', 'HEAD', 'OPTIONS'].includes(req.method)) {
    return next();
  }

  const cookieToken = req.cookies.csrf;
  const headerToken = req.headers['x-csrf-token'];

  if (!cookieToken || !headerToken || cookieToken !== headerToken) {
    return res.status(403).json({ error: 'CSRF validation failed' });
  }

  next();
}

app.use(doubleSubmitValidation);

Frontend code to read the cookie and include it in requests:

// Read CSRF cookie
function getCsrfToken() {
  const match = document.cookie.match(/csrf=([^;]+)/);
  return match ? match[1] : null;
}

// Include in fetch requests
async function secureFetch(url, options = {}) {
  const csrfToken = getCsrfToken();

  return fetch(url, {
    ...options,
    credentials: 'include',
    headers: {
      ...options.headers,
      'X-CSRF-Token': csrfToken,
      'Content-Type': 'application/json',
    },
  });
}

// Usage
await secureFetch('/api/transfer', {
  method: 'POST',
  body: JSON.stringify({ to: 'friend', amount: 50 }),
});

Prevention Strategy 3: Origin and Referer Validation

The server can check the Origin or Referer header to verify the request came from the same site. This is a supplementary defense, not a primary one (headers can be absent in some cases).

function validateOrigin(req, res, next) {
  if (['GET', 'HEAD', 'OPTIONS'].includes(req.method)) {
    return next();
  }

  const origin = req.headers.origin || req.headers.referer;
  const allowedOrigins = [
    'https://myapp.com',
    'https://www.myapp.com',
  ];

  if (!origin) {
    // Origin header missing — could be a same-site request
    // or privacy-stripping proxy. Decide based on risk tolerance.
    return res.status(403).json({ error: 'Origin header required' });
  }

  const requestOrigin = new URL(origin).origin;
  if (!allowedOrigins.includes(requestOrigin)) {
    return res.status(403).json({ error: 'Cross-origin request blocked' });
  }

  next();
}

Prevention Strategy 4: Custom Request Headers

Browsers enforce CORS preflight for requests with custom headers. If your API requires a custom header (like X-Requested-With), simple cross-origin requests (which skip preflight) will fail:

// Server: require custom header
function requireCustomHeader(req, res, next) {
  if (['GET', 'HEAD', 'OPTIONS'].includes(req.method)) {
    return next();
  }

  if (req.headers['x-requested-with'] !== 'XMLHttpRequest') {
    return res.status(403).json({ error: 'Custom header required' });
  }

  next();
}

// Client: always include the header
fetch('/api/action', {
  method: 'POST',
  headers: {
    'X-Requested-With': 'XMLHttpRequest',
    'Content-Type': 'application/json',
  },
  body: JSON.stringify(data),
});

This works because a cross-origin form submission cannot set custom headers. However, this relies on CORS being correctly configured — if your CORS policy allows the attacker's origin, this defense fails.

When the Frontend Cannot Protect Against CSRF

The frontend alone cannot fully prevent CSRF. Here is why:

+-----------------------------------+-----------------------------------------+
| Scenario                          | Why Frontend Cannot Protect             |
+-----------------------------------+-----------------------------------------+
| Form-based CSRF                   | No JS executes — direct form submit     |
| Image/link-based CSRF (GET)       | No JS involved — browser makes request  |
| Cookie sent automatically         | Frontend cannot prevent cookie attachment|
| Attacker does not need JS         | HTML-only attacks bypass JS defenses    |
+-----------------------------------+-----------------------------------------+

What the frontend CAN do:

  • Include CSRF tokens in requests (but the server must validate them)
  • Read and send double-submit cookies
  • Add custom headers that trigger CORS preflight
  • Store auth tokens in memory/localStorage instead of cookies (eliminates CSRF entirely, but introduces XSS risks)

What the server MUST do:

  • Validate CSRF tokens
  • Set SameSite cookie attributes
  • Validate Origin/Referer headers
  • Never perform state changes on GET requests

CSRF Prevention in Next.js

// middleware.ts — Next.js middleware for CSRF protection
import { NextRequest, NextResponse } from 'next/server';

export function middleware(request: NextRequest) {
  // Only check state-changing methods
  if (['GET', 'HEAD', 'OPTIONS'].includes(request.method)) {
    return NextResponse.next();
  }

  // Check Origin header
  const origin = request.headers.get('origin');
  const host = request.headers.get('host');

  if (!origin) {
    return new NextResponse('Forbidden: missing origin', { status: 403 });
  }

  const originHost = new URL(origin).host;
  if (originHost !== host) {
    return new NextResponse('Forbidden: cross-origin', { status: 403 });
  }

  return NextResponse.next();
}

export const config = {
  matcher: '/api/:path*',
};

API route with CSRF token validation:

// pages/api/transfer.ts
import type { NextApiRequest, NextApiResponse } from 'next';

export default function handler(req: NextApiRequest, res: NextApiResponse) {
  if (req.method !== 'POST') {
    return res.status(405).json({ error: 'Method not allowed' });
  }

  // Validate CSRF token (double-submit pattern)
  const cookieToken = req.cookies['csrf-token'];
  const headerToken = req.headers['x-csrf-token'];

  if (!cookieToken || !headerToken || cookieToken !== headerToken) {
    return res.status(403).json({ error: 'Invalid CSRF token' });
  }

  // Process the request
  const { recipient, amount } = req.body;
  // ... transfer logic
  res.json({ success: true });
}

CSRF vs XSS: Key Differences

+------------------+----------------------------+----------------------------+
|                  | CSRF                       | XSS                        |
+------------------+----------------------------+----------------------------+
| Attack type      | Forges requests            | Injects scripts            |
| Exploits trust   | Server trusts browser      | User trusts website        |
| Reads data?      | No                         | Yes                        |
| Writes data?     | Yes (state changes)        | Yes                        |
| Needs JS?        | No (HTML forms suffice)    | Yes (script execution)     |
| Cookie access?   | Indirect (browser sends)   | Direct (document.cookie)   |
| Defense          | Tokens, SameSite, Origin   | Encoding, CSP, sanitize    |
+------------------+----------------------------+----------------------------+

Important relationship: XSS can bypass CSRF defenses. If an attacker achieves XSS on your site, they can read CSRF tokens from the DOM and include them in forged requests. This is why XSS prevention is foundational — CSRF protections assume the attacker cannot execute JavaScript on your domain.

Complete CSRF Prevention Checklist

+------+----------------------------------------------+----------+
| #    | Check                                        | Status   |
+------+----------------------------------------------+----------+
| 1    | Session cookies use SameSite=Strict or Lax   | [ ]      |
| 2    | Session cookies use HttpOnly                 | [ ]      |
| 3    | Session cookies use Secure (HTTPS only)      | [ ]      |
| 4    | CSRF tokens on all state-changing forms       | [ ]      |
| 5    | Server validates CSRF token on POST/PUT/DEL   | [ ]      |
| 6    | No state changes on GET requests              | [ ]      |
| 7    | Origin/Referer header validated               | [ ]      |
| 8    | CORS restricted to trusted origins            | [ ]      |
| 9    | Custom headers required on API requests       | [ ]      |
| 10   | XSS prevention in place (CSRF depends on it)  | [ ]      |
+------+----------------------------------------------+----------+

Summary

CSRF exploits the browser's automatic cookie-sending behavior to perform actions on behalf of authenticated users. The attack does not require the attacker to steal credentials — they just trick the browser into making a request with valid cookies attached.

Primary defenses:

  1. SameSite cookies — The simplest and most effective defense. Use Strict for sensitive cookies, Lax for general session cookies.
  2. CSRF tokens — Synchronizer pattern (server stores token) or double-submit pattern (stateless). Server must validate on every state-changing request.
  3. Origin validation — Check Origin and Referer headers as a secondary layer.
  4. Custom request headers — Force CORS preflight to block simple cross-origin requests.

Critical reminder: CSRF defenses assume XSS is not present. If an attacker can execute JavaScript on your domain (via XSS), they can bypass all CSRF protections. Fix XSS first, then layer CSRF defenses on top.

Found this helpful?

Support devsofus — help us keep creating free dev guides.

Related Articles