CSRF Protection

Patterns for Cross-Site Request Forgery protection in Next.js applications.

Overview#

CSRF attacks trick authenticated users into submitting malicious requests. This pattern covers:

  • Token-based CSRF protection
  • Double submit cookie pattern
  • SameSite cookie protection
  • Middleware integration

Prerequisites#

# No additional packages required - uses Node.js crypto

Code Example#

Token-Based CSRF Protection#

1// lib/csrf.ts 2import { randomBytes, createHmac } from 'crypto' 3import { cookies } from 'next/headers' 4 5const CSRF_SECRET = process.env.CSRF_SECRET! 6const CSRF_COOKIE = 'csrf_token' 7const CSRF_HEADER = 'x-csrf-token' 8 9export function generateCsrfToken(): string { 10 const token = randomBytes(32).toString('hex') 11 const signature = createHmac('sha256', CSRF_SECRET) 12 .update(token) 13 .digest('hex') 14 15 return `${token}.${signature}` 16} 17 18export function validateCsrfToken(token: string): boolean { 19 const [value, signature] = token.split('.') 20 21 if (!value || !signature) return false 22 23 const expectedSignature = createHmac('sha256', CSRF_SECRET) 24 .update(value) 25 .digest('hex') 26 27 return signature === expectedSignature 28} 29 30export async function setCsrfCookie() { 31 const token = generateCsrfToken() 32 const cookieStore = await cookies() 33 34 cookieStore.set(CSRF_COOKIE, token, { 35 httpOnly: true, 36 secure: process.env.NODE_ENV === 'production', 37 sameSite: 'strict', 38 path: '/' 39 }) 40 41 return token 42} 43 44export async function verifyCsrfToken(headerToken: string | null): Promise<boolean> { 45 if (!headerToken) return false 46 47 const cookieStore = await cookies() 48 const cookieToken = cookieStore.get(CSRF_COOKIE)?.value 49 50 if (!cookieToken) return false 51 52 return headerToken === cookieToken && validateCsrfToken(headerToken) 53}

CSRF Middleware#

1// middleware.ts 2import { NextResponse } from 'next/server' 3import type { NextRequest } from 'next/server' 4 5const SAFE_METHODS = ['GET', 'HEAD', 'OPTIONS'] 6 7export function middleware(request: NextRequest) { 8 // Skip CSRF check for safe methods 9 if (SAFE_METHODS.includes(request.method)) { 10 return NextResponse.next() 11 } 12 13 // Skip for API routes that use other auth (API keys, webhooks) 14 if (request.nextUrl.pathname.startsWith('/api/webhooks')) { 15 return NextResponse.next() 16 } 17 18 const csrfHeader = request.headers.get('x-csrf-token') 19 const csrfCookie = request.cookies.get('csrf_token')?.value 20 21 if (!csrfHeader || !csrfCookie || csrfHeader !== csrfCookie) { 22 return NextResponse.json( 23 { error: 'Invalid CSRF token' }, 24 { status: 403 } 25 ) 26 } 27 28 return NextResponse.next() 29} 30 31export const config = { 32 matcher: '/api/:path*' 33}

CSRF Token Provider#

1// components/providers/CsrfProvider.tsx 2'use client' 3 4import { createContext, useContext, useEffect, useState } from 'react' 5 6const CsrfContext = createContext<string | null>(null) 7 8export function CsrfProvider({ children }: { children: React.ReactNode }) { 9 const [token, setToken] = useState<string | null>(null) 10 11 useEffect(() => { 12 fetch('/api/csrf') 13 .then(res => res.json()) 14 .then(data => setToken(data.token)) 15 }, []) 16 17 return ( 18 <CsrfContext.Provider value={token}> 19 {children} 20 </CsrfContext.Provider> 21 ) 22} 23 24export function useCsrfToken() { 25 return useContext(CsrfContext) 26}

Protected Form#

1// components/forms/ProtectedForm.tsx 2'use client' 3 4import { useCsrfToken } from '@/components/providers/CsrfProvider' 5 6export function ProtectedForm() { 7 const csrfToken = useCsrfToken() 8 9 async function handleSubmit(e: React.FormEvent<HTMLFormElement>) { 10 e.preventDefault() 11 const formData = new FormData(e.currentTarget) 12 13 const response = await fetch('/api/submit', { 14 method: 'POST', 15 headers: { 16 'Content-Type': 'application/json', 17 'X-CSRF-Token': csrfToken ?? '' 18 }, 19 body: JSON.stringify(Object.fromEntries(formData)) 20 }) 21 22 // Handle response... 23 } 24 25 return ( 26 <form onSubmit={handleSubmit}> 27 <input type="hidden" name="csrf_token" value={csrfToken ?? ''} /> 28 {/* Form fields */} 29 <button type="submit">Submit</button> 30 </form> 31 ) 32}
1// lib/session.ts 2// Modern approach: rely on SameSite cookies 3import { cookies } from 'next/headers' 4 5export async function setSessionCookie(sessionId: string) { 6 const cookieStore = await cookies() 7 8 cookieStore.set('session', sessionId, { 9 httpOnly: true, 10 secure: true, 11 sameSite: 'lax', // or 'strict' for more protection 12 path: '/', 13 maxAge: 60 * 60 * 24 * 7 // 1 week 14 }) 15} 16 17// SameSite=Lax: Cookie sent with top-level navigations and GET from third-party 18// SameSite=Strict: Cookie only sent in first-party context 19// Combined with secure: true, this prevents most CSRF attacks

Usage Instructions#

  1. Generate CSRF tokens for each session or form
  2. Include the token in a cookie and require it in request headers
  3. Validate tokens on all state-changing requests (POST, PUT, DELETE)
  4. Skip validation for safe methods and webhook endpoints
  5. Use SameSite cookies as an additional layer of protection

Best Practices#

  • Use strong secrets - Generate CSRF_SECRET with openssl rand -base64 32
  • Validate on server - Never trust client-side validation alone
  • Rotate tokens - Generate new tokens periodically or per-request
  • Skip webhooks - Webhooks use signature verification instead
  • Combine with SameSite - Use both CSRF tokens and SameSite cookies
  • Handle failures gracefully - Return clear error messages