Skip to content

Cross-Site Request Forgery Protection Strategies

A complete guide to understanding and preventing CSRF attacks: token-based protection, SameSite cookies, double-submit and framework implementations.

5 min read
Diagram showing CSRF attack flow and token-based mitigation strategy

Cross-Site Request Forgery (CSRF) exploits browser trust in cookies. When a user is authenticated on your site, their browser automatically sends cookies with every request — even requests triggered by a malicious third-party page. CSRF tricks the browser into making authenticated requests the user never intended.

Despite being well-understood, CSRF vulnerabilities remain common because developers assume modern frameworks handle it automatically. Some do. Many do not, especially in SPA-plus-API architectures.

How CSRF Works

The attack requires three conditions: the user is authenticated (cookies present), the target action uses cookies for authentication, and the request is "simple" enough that the browser sends it without a preflight check.

htmlhtml
<!-- ❌ Malicious page hosted on attacker.com -->
<!-- User visits this page while logged into bank.com -->
 
<!-- Hidden form auto-submits on page load -->
<form id="evil" action="https://bank.com/api/transfer" method="POST">
  <input type="hidden" name="to" value="attacker-account" />
  <input type="hidden" name="amount" value="10000" />
</form>
 
<script>
  document.getElementById('evil').submit();
</script>
 
<!-- Browser sends bank.com cookies automatically.
     bank.com sees a valid authenticated request
     and processes the transfer. -->

The user never clicked a transfer button. They just visited a page. The browser did the rest because cookies do not distinguish between requests the user initiated and requests a malicious page triggered.

Synchronizer Token Pattern

The most common CSRF defense: generate a random token server-side, embed it in the page, and validate it on every state-changing request.

tstypescript
import crypto from 'crypto';
 
// Generate a CSRF token and store it in the session
function generateCsrfToken(session: Session): string {
  const token = crypto.randomBytes(32).toString('hex');
  session.csrfToken = token;
  return token;
}
 
// Middleware: validate token on state-changing requests
function csrfProtection(req: Request, res: Response, next: NextFunction) {
  if (['GET', 'HEAD', 'OPTIONS'].includes(req.method)) {
    return next(); // Safe methods don't need CSRF protection
  }
 
  const sessionToken = req.session?.csrfToken;
  const requestToken =
    req.headers['x-csrf-token'] ??
    req.body?._csrf;
 
  if (!sessionToken || !requestToken) {
    return res.status(403).json({ error: 'CSRF token missing' });
  }
 
  // Constant-time comparison prevents timing attacks
  const isValid = crypto.timingSafeEqual(
    Buffer.from(sessionToken),
    Buffer.from(requestToken as string)
  );
 
  if (!isValid) {
    return res.status(403).json({ error: 'CSRF token invalid' });
  }
 
  next();
}
htmlhtml
<!-- Embed the token in forms -->
<form action="/api/transfer" method="POST">
  <input type="hidden" name="_csrf" value="{{csrfToken}}" />
  <input type="text" name="to" placeholder="Recipient" />
  <input type="number" name="amount" placeholder="Amount" />
  <button type="submit">Transfer</button>
</form>

The attacker cannot read the CSRF token because same-origin policy prevents cross-origin page reads. They can submit a form to your domain, but they cannot include the token they do not have.

For stateless APIs that do not use server-side sessions, the double submit pattern uses a cookie-header pair instead of a session-stored token.

tstypescript
// ❌ Stateless API with no CSRF protection
app.post('/api/settings', authenticate, (req, res) => {
  // Authenticates via cookie — vulnerable to CSRF
  updateSettings(req.user.id, req.body);
  res.json({ success: true });
});
tstypescript
// ✅ Double submit cookie pattern
import crypto from 'crypto';
 
// On login: set a CSRF cookie (not HttpOnly — JS must read it)
function setCsrfCookie(res: Response): void {
  const token = crypto.randomBytes(32).toString('hex');
  res.cookie('csrf-token', token, {
    sameSite: 'strict',
    secure: true,
    httpOnly: false,  // JavaScript must read this cookie
    path: '/',
  });
}
 
// Middleware: compare cookie value with header value
function doubleSubmitCsrf(req: Request, res: Response, next: NextFunction) {
  if (['GET', 'HEAD', 'OPTIONS'].includes(req.method)) {
    return next();
  }
 
  const cookieToken = req.cookies['csrf-token'];
  const headerToken = req.headers['x-csrf-token'];
 
  if (!cookieToken || !headerToken) {
    return res.status(403).json({ error: 'CSRF token missing' });
  }
 
  const isValid = crypto.timingSafeEqual(
    Buffer.from(cookieToken),
    Buffer.from(headerToken as string)
  );
 
  if (!isValid) {
    return res.status(403).json({ error: 'CSRF token mismatch' });
  }
 
  next();
}
 
// Client-side: read cookie and send as header
async function apiRequest(url: string, data: unknown) {
  const csrfToken = document.cookie
    .split('; ')
    .find(row => row.startsWith('csrf-token='))
    ?.split('=')[1];
 
  return fetch(url, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'X-CSRF-Token': csrfToken ?? '',
    },
    credentials: 'include',
    body: JSON.stringify(data),
  });
}

The attacker can trigger a request that sends the cookie, but they cannot read the cookie value (same-origin policy) and therefore cannot set the matching header. The server rejects requests where the cookie and header do not match.

Modern browsers support the SameSite cookie attribute, which prevents the browser from sending cookies with cross-origin requests.

tstypescript
// ❌ Cookie without SameSite — sent on all requests including cross-origin
res.cookie('session', sessionId, {
  httpOnly: true,
  secure: true,
});
 
// ✅ SameSite=Strict — cookie only sent on same-origin navigation
res.cookie('session', sessionId, {
  httpOnly: true,
  secure: true,
  sameSite: 'strict',
});
 
// ✅ SameSite=Lax — sent on same-origin + top-level GET navigations
res.cookie('session', sessionId, {
  httpOnly: true,
  secure: true,
  sameSite: 'lax',    // Good default — allows "Login with Google" flows
});

SameSite=Lax is the default in modern browsers and prevents CSRF on POST requests. Strict provides stronger protection but breaks legitimate cross-site navigation flows (clicking a link to your site from an email will not include the session cookie).

SameSite is defense-in-depth. Do not rely on it alone — older browsers do not support it, and Lax still allows GET-based CSRF for state-changing GET endpoints (which should not exist, but sometimes do).

Custom Header Requirement

For APIs consumed only by JavaScript (not form submissions), requiring a custom header that browsers do not send automatically is a simple defense.

tstypescript
// Middleware: require a custom header on all requests
function requireCustomHeader(req: Request, res: Response, next: NextFunction) {
  if (['GET', 'HEAD', 'OPTIONS'].includes(req.method)) {
    return next();
  }
 
  // Browsers don't add X-Requested-With to form submissions
  // Only JavaScript can set custom headers (triggers CORS preflight)
  if (req.headers['x-requested-with'] !== 'XMLHttpRequest') {
    return res.status(403).json({ error: 'Missing required header' });
  }
 
  next();
}
 
// Client-side: add the header to all requests
const api = {
  post: (url: string, data: unknown) =>
    fetch(url, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        'X-Requested-With': 'XMLHttpRequest',
      },
      credentials: 'include',
      body: JSON.stringify(data),
    }),
};

This works because a cross-origin form submission cannot set custom headers. Setting Content-Type: application/json alone triggers a CORS preflight, which blocks the request unless the server explicitly allows the origin. However, this defense requires proper CORS configuration — if you allow all origins with Access-Control-Allow-Origin: *, this protection is weakened.

Defense in Depth

No single CSRF defense is sufficient alone. Layer multiple strategies.

tstypescript
// Production CSRF configuration — multiple layers
const csrfConfig = {
  // Layer 1: SameSite cookies (browser-level)
  cookieOptions: {
    sameSite: 'lax' as const,
    secure: true,
    httpOnly: true,
  },
 
  // Layer 2: CSRF token validation (application-level)
  tokenValidation: true,
 
  // Layer 3: Origin/Referer header check
  originCheck: true,
 
  // Layer 4: Custom header requirement for API routes
  customHeaderRequired: true,
};
 
function fullCsrfProtection(req: Request, res: Response, next: NextFunction) {
  if (['GET', 'HEAD', 'OPTIONS'].includes(req.method)) {
    return next();
  }
 
  // Check Origin header
  const origin = req.headers.origin ?? req.headers.referer;
  if (origin) {
    const allowedOrigins = [process.env.APP_URL];
    const requestOrigin = new URL(origin).origin;
    if (!allowedOrigins.includes(requestOrigin)) {
      return res.status(403).json({ error: 'Origin not allowed' });
    }
  }
 
  // Validate CSRF token (synchronizer or double-submit)
  // ... token validation logic ...
 
  next();
}

Key Takeaways

  1. CSRF exploits automatic cookie inclusion — browsers send cookies with cross-origin requests by default
  2. Use SameSite=Lax cookies as a baseline defense — prevents CSRF on POST but allows normal navigation
  3. Implement token-based protection for session-based apps — synchronizer tokens or double-submit cookies
  4. Require custom headers on API endpoints — browsers do not add custom headers to form submissions
  5. Use constant-time comparison for token validation — crypto.timingSafeEqual prevents timing attacks
  6. Layer defenses — SameSite cookies + token validation + origin checking together provide robust protection
Wilfredo Rujel

Wilfredo Rujel

Full Stack Software Engineer

Share this postX