Application Security
csrf
cross-site request forgery
samesite
cookies
+2 more

CSRF Attacks Explained: Tokens, SameSite Cookies & Modern Defenses

SCRs Team
March 10, 2026
14 min read
Share

What Is CSRF and Why Does It Still Work?

Cross-Site Request Forgery (CSRF) tricks a user's browser into making unintended requests to a site where they're already authenticated. The browser automatically includes cookies — so the server thinks it's a legitimate request.

How a CSRF Attack Works

1. User logs into bank.com (session cookie set)
2. User visits evil.com (in another tab)
3. evil.com contains hidden form:

<form action="https://bank.com/transfer" method="POST">
  <input type="hidden" name="to" value="attacker-account" />
  <input type="hidden" name="amount" value="10000" />
</form>
<script>document.forms[0].submit();</script>

4. Browser sends the request WITH the user's bank.com session cookie
5. Bank processes the transfer — it looks legitimate

This works because the browser automatically attaches cookies to any request matching the cookie's domain, regardless of which website initiated the request.


CSRF Attack Variants

1. Form-Based CSRF (Classic)

<!-- Hidden auto-submitting form -->
<body onload="document.getElementById('csrf').submit()">
  <form id="csrf" action="https://target.com/api/change-email" method="POST">
    <input type="hidden" name="email" value="attacker@evil.com" />
  </form>
</body>

2. Image Tag CSRF (GET Requests)

<!-- Triggers GET request silently -->
<img src="https://target.com/api/delete-account?confirm=true" width="0" height="0" />

3. XMLHttpRequest CSRF

// Works if CORS allows it or for simple requests
fetch('https://target.com/api/change-password', {
  method: 'POST',
  credentials: 'include', // Sends cookies
  headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
  body: 'new_password=hacked123'
});

4. JSON-Based CSRF

<!-- Exploiting content-type bypass -->
<form action="https://target.com/api/update-profile" method="POST" enctype="text/plain">
  <input name='{"role":"admin","ignore":"' value='"}' />
</form>

This sends: {"role":"admin","ignore":"="} — valid JSON that changes the user's role.


Defense #1: CSRF Tokens (Synchronizer Pattern)

The most reliable defense. Generate a random token, embed it in forms, and verify on the server.

// Server: Generate CSRF token
import crypto from 'crypto';

function generateCsrfToken(session: Session): string {
  const token = crypto.randomBytes(32).toString('hex');
  session.csrfToken = token;
  return token;
}

// Server: Verify CSRF token
function verifyCsrfToken(req: Request, session: Session): boolean {
  const token = req.body._csrf || req.headers['x-csrf-token'];
  if (!token || token !== session.csrfToken) {
    return false;
  }
  // Rotate token after use (one-time use)
  session.csrfToken = crypto.randomBytes(32).toString('hex');
  return true;
}

// Express middleware
function csrfProtection(req: Request, res: Response, next: NextFunction) {
  if (['GET', 'HEAD', 'OPTIONS'].includes(req.method)) {
    return next(); // Safe methods don't need CSRF protection
  }
  
  if (!verifyCsrfToken(req, req.session)) {
    return res.status(403).json({ error: 'Invalid CSRF token' });
  }
  next();
}
<!-- Client: Include token in forms -->
<form method="POST" action="/api/transfer">
  <input type="hidden" name="_csrf" value="{{csrfToken}}" />
  <input name="amount" />
  <button type="submit">Transfer</button>
</form>

Defense #2: SameSite Cookies

// Set SameSite attribute on session cookies
res.cookie('session', token, {
  httpOnly: true,
  secure: true,
  sameSite: 'lax',   // Blocks cross-site POST requests
  // sameSite: 'strict' // Blocks ALL cross-site requests (breaks OAuth flows)
  maxAge: 3600000,
});
SameSite ValueCross-Site POSTCross-Site GETOAuth Flows
None✅ Sent (vulnerable)✅ Sent✅ Works
Lax (default)❌ Blocked✅ Sent (top-level)✅ Works
Strict❌ Blocked❌ Blocked❌ Breaks

Why SameSite Isn't Enough Alone

  1. Lax allows GET requests — if your app has state-changing GET endpoints, they're still vulnerable
  2. Subdomain bypass — attacker on sub.target.com can still send cookies
  3. Browser bugs — Some older browsers don't enforce SameSite properly
  4. WebSocket bypass — SameSite doesn't apply to WebSocket connections

Always combine SameSite cookies with CSRF tokens for defense-in-depth.


For stateless APIs where you can't store tokens in sessions:

// Set a random CSRF cookie
const csrfValue = crypto.randomBytes(32).toString('hex');
res.cookie('csrf', csrfValue, { 
  httpOnly: false, // Client needs to read this
  secure: true, 
  sameSite: 'lax' 
});

// Middleware: Verify cookie value matches header value
function doubleSubmitCheck(req: Request, res: Response, next: NextFunction) {
  const cookieValue = req.cookies.csrf;
  const headerValue = req.headers['x-csrf-token'];
  
  if (!cookieValue || cookieValue !== headerValue) {
    return res.status(403).json({ error: 'CSRF validation failed' });
  }
  next();
}
// Client: Read cookie and send as header
const csrfToken = document.cookie
  .split('; ')
  .find(c => c.startsWith('csrf='))
  ?.split('=')[1];

fetch('/api/transfer', {
  method: 'POST',
  headers: { 'X-CSRF-Token': csrfToken },
  body: JSON.stringify({ amount: 100 }),
});

Framework-Specific CSRF Protection

Next.js Server Actions

Next.js Server Actions have built-in CSRF protection via the Next-Action header — but verify this is enforced in your version.

Django

# Built-in — just use the template tag
{% csrf_token %}

# For AJAX, read the csrftoken cookie and send as X-CSRFToken header

Express

import { doubleCsrf } from 'csrf-csrf';

const { doubleCsrfProtection, generateToken } = doubleCsrf({
  getSecret: () => process.env.CSRF_SECRET!,
  cookieName: '__csrf',
  cookieOptions: { secure: true, sameSite: 'lax' },
});

app.use(doubleCsrfProtection);

CSRF Testing Checklist

  • All state-changing endpoints require CSRF tokens
  • Session cookies have SameSite=Lax or Strict
  • No state-changing operations on GET requests
  • CSRF tokens are random, unique per session, and verified server-side
  • Custom headers required for API calls (X-Requested-With, X-CSRF-Token)
  • Content-Type validation rejects unexpected text/plain submissions
  • WebSocket connections verify origin header

Advertisement