How to Implement CSRF Protection

Share
How-To Guide

How to Implement CSRF Protection

Prevent unauthorized actions from malicious websites

TL;DR

TL;DR

Use SameSite=Lax or Strict cookies for session tokens. For APIs using cookies, add CSRF tokens to forms and verify them server-side. If you use Bearer tokens in Authorization headers (not cookies), you likely don't need CSRF protection.

What is CSRF?

Cross-Site Request Forgery happens when a malicious website tricks your browser into making requests to another site where you're logged in. Because cookies are sent automatically, the malicious request appears legitimate.

<!-- Malicious website has this hidden form -->
<form action="https://yourbank.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>

<!-- If you're logged into yourbank.com, your session cookie
     is sent automatically with this request! -->

Do You Need CSRF Protection?

You DON'T need CSRF tokens if:

  • You use Bearer tokens in Authorization headers (not cookies)
  • Your API only accepts JSON with proper Content-Type headers
  • You use SameSite=Strict cookies

You DO need CSRF protection if:

  • You use session cookies for authentication
  • Your forms use traditional form submissions
  • You need to support older browsers without SameSite support

Method 1: SameSite Cookies (Simplest)

The easiest protection is using SameSite cookies. This prevents cookies from being sent on cross-origin requests.

// Set cookie with SameSite attribute
res.cookie('session', sessionId, {
  httpOnly: true,
  secure: process.env.NODE_ENV === 'production',
  sameSite: 'lax', // or 'strict' for maximum protection
  maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days
});

// SameSite options:
// 'strict' - Cookie only sent for same-site requests
// 'lax' - Cookie sent for same-site + top-level navigations (links)
// 'none' - Cookie sent for all requests (requires secure: true)

Method 2: CSRF Tokens (Traditional)

Generate a random token, store it in the session, and require it in forms.

1

Install a CSRF library

npm install csrf
2

Generate and verify tokens

import Tokens from 'csrf';

const tokens = new Tokens();

// Generate a secret (store in session)
const secret = tokens.secretSync();

// Generate a token for the form
const csrfToken = tokens.create(secret);

// Verify the token
const isValid = tokens.verify(secret, submittedToken);
3

Include token in forms

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

Next.js Implementation

Server Actions (App Router)

Next.js Server Actions have built-in CSRF protection. The framework validates that the request came from your application.

// This is protected automatically
'use server';

export async function transferMoney(formData: FormData) {
  const amount = formData.get('amount');
  // Process transfer
}

API Routes with Cookies

// lib/csrf.ts
import Tokens from 'csrf';
import { cookies } from 'next/headers';

const tokens = new Tokens();

export function generateCsrfToken() {
  const secret = tokens.secretSync();
  const token = tokens.create(secret);

  // Store secret in cookie
  cookies().set('csrf-secret', secret, {
    httpOnly: true,
    secure: process.env.NODE_ENV === 'production',
    sameSite: 'lax',
  });

  return token;
}

export function verifyCsrfToken(token: string): boolean {
  const secret = cookies().get('csrf-secret')?.value;
  if (!secret) return false;
  return tokens.verify(secret, token);
}

// app/api/transfer/route.ts
import { verifyCsrfToken } from '@/lib/csrf';

export async function POST(request: Request) {
  const body = await request.json();

  if (!verifyCsrfToken(body._csrf)) {
    return Response.json(
      { error: 'Invalid CSRF token' },
      { status: 403 }
    );
  }

  // Process the request
}

Express.js Implementation

import express from 'express';
import csrf from 'csurf';
import cookieParser from 'cookie-parser';

const app = express();
app.use(cookieParser());
app.use(express.urlencoded({ extended: false }));

// Enable CSRF protection
const csrfProtection = csrf({ cookie: true });

// Apply to state-changing routes
app.post('/transfer', csrfProtection, (req, res) => {
  // Token is verified automatically
  // If invalid, middleware returns 403
  res.json({ success: true });
});

// Provide token to forms
app.get('/form', csrfProtection, (req, res) => {
  res.render('form', { csrfToken: req.csrfToken() });
});

An alternative that doesn't require server-side session storage.

// Set CSRF cookie (readable by JavaScript)
res.cookie('csrf-token', token, {
  httpOnly: false, // Must be readable by JS
  secure: true,
  sameSite: 'strict',
});

// Client reads cookie and sends in header
fetch('/api/transfer', {
  method: 'POST',
  headers: {
    'X-CSRF-Token': getCookie('csrf-token'),
    'Content-Type': 'application/json',
  },
  body: JSON.stringify(data),
});

// Server compares cookie value with header value
const cookieToken = req.cookies['csrf-token'];
const headerToken = req.headers['x-csrf-token'];
if (cookieToken !== headerToken) {
  return res.status(403).json({ error: 'CSRF validation failed' });
}

For SPAs with JSON APIs

If your API only accepts JSON and uses proper CORS, you get protection automatically:

// Only accept JSON content type
app.use((req, res, next) => {
  if (['POST', 'PUT', 'PATCH', 'DELETE'].includes(req.method)) {
    const contentType = req.headers['content-type'];
    if (!contentType?.includes('application/json')) {
      return res.status(415).json({ error: 'Content-Type must be application/json' });
    }
  }
  next();
});

// Combined with SameSite cookies, this blocks CSRF attacks
// because browsers can't send JSON with custom Content-Type cross-origin

Testing CSRF Protection

# Test without CSRF token (should fail)
curl -X POST http://localhost:3000/api/transfer \
  -H "Cookie: session=your-session-cookie" \
  -d "amount=100"
# Expected: 403 Forbidden

# Test with CSRF token (should succeed)
curl -X POST http://localhost:3000/api/transfer \
  -H "Cookie: session=your-session-cookie" \
  -H "X-CSRF-Token: valid-token" \
  -d "amount=100"
# Expected: 200 OK

Quick Reference

Auth MethodCSRF RiskProtection
Cookie sessionsHighSameSite + CSRF tokens
Bearer tokens (localStorage)NoneNot needed (but XSS risk)
Bearer tokens (httpOnly cookie)HighSameSite + CSRF tokens
How-To Guides

How to Implement CSRF Protection