Add CSRF Protection to NextJS Middleware

Add Cross-Site Request Forgery (CSRF) protection to your NextJS middleware to prevent CSRF attacks.

When using session cookies for authentication, you must protect against CSRF attacks. CSRF is a type of web security vulnerability where an attacker tricks a user into unknowingly submitting a request to a web application where they're already authenticated. Since browsers automatically include cookies with requests, the malicious request appears legitimate to the server. This allows attackers to perform actions on behalf of the user without their consent.

For modern browsers, CSRF attacks can be prevented by using the SameSite cookie attribute. Setting the SameSite cookie attribute to Lax or Strict will ensure that the browser won't send the cookie for cross-site requests, eliminating the threat of CSRF. However, for older browsers that don't support the SameSite cookie attribute, you'll need to implement your own CSRF protection. One common approach for building CSRF protection is to use the Signed Double Submit Cookie pattern.


Implement Signed Double Submit Cookie Pattern

The Signed Double Submit Cookie pattern is a stateless solution for preventing CSRF attacks. For a high-level overview of how the Signed Double Submit Cookie pattern works, please refer to the following documentation.


Install CSRF SDK

To help implement the Signed Double Submit Cookie pattern, we'll use the edge csrf library.

npm install @edge-csrf/core
yarn add @edge-csrf/core
pnpm add @edge-csrf/core

Add CSRF Utility Functions

Next, you'll need to create a couple of utility functions for use both within the middleware and the Callback Endpoint.

//
// Works For Both Page Router And App Router
// Location -> src/utils/csrf.ts
//
import { NextApiResponse } from 'next';
import { NextRequest, NextResponse } from 'next/server';
import { atou, createSecret, createToken, utoa, verifyToken } from '@edge-csrf/core';

// Generates a new CSRF secret.
export function createCsrfSecret(): string {
  return utoa(createSecret(18));
}

// Verifies that the CSRF token in the request header was cryptographically generated from the
// CSRF secret in your user's session.
export async function isCsrfTokenValid(req: NextRequest, csrfSecret: string): Promise<boolean> {
  const token = req.headers.get('x-csrf-token');
  if (!token || !csrfSecret) {
    return false;
  }

  return await verifyToken(atou(token), atou(csrfSecret));
}

// Updates the CSRF cookie with a new CSRF token.
export async function setCsrfTokenCookie(csrfSecret: string, res: Response | NextResponse | NextApiResponse) {
  const csrfToken = await createToken(atou(csrfSecret), 8);
  // IMPORTANT: Only set this to false if your server isn't using HTTPS.
  const isSecure = process.env.NODE_ENV === 'production';

  // Construct the value for the "Set-Cookie" header for Edge runtime and Page Router.
  const cookieValue = `${'CSRF-TOKEN'}=${utoa(csrfToken)}; Path=/; HttpOnly; SameSite=Strict; Max-Age=1800${isSecure ? '; Secure' : ''}`;

  // Check for NextResponse cookies API first
  if ('cookies' in res && typeof res.cookies?.set === 'function') {
    // Use NextResponse's built-in API
    res.cookies.set('CSRF-TOKEN', utoa(csrfToken), {
      path: '/',
      httpOnly: false, // Must be false so that your frontend JavaScript code can access the value.
      sameSite: 'strict', // This should match the SameSite settings of your session cookie.
      maxAge: 1800, // This value should match your session cookie expiration, e.g. 30 mins
      secure: isSecure // IMPORTANT: Only set this to false if your server isn't using HTTPS.
    });
  } 
  // Standard Response (Edge runtime)
  else if (res instanceof Response) {
    res.headers.append('Set-Cookie', cookieValue);
  } 
  // NextApiResponse (Pages Router)
  else if ('setHeader' in res) {
    let existingCookies = res.getHeader('Set-Cookie') || [];
    if (!Array.isArray(existingCookies)) {
      existingCookies = [existingCookies.toString()];
    }
    res.setHeader('Set-Cookie', [...existingCookies, cookieValue]);
  } 
  else {
    throw new Error('Unsupported response object: Unable to set cookies');
  }
}


Implement CSRF Check in Middleware For All Protected Endpoints

Next, we'll update the middleware to check whether the CSRF token in the request header can be computed using the CSRF secret in the session cookie. If the CSRF token verification fails, then a 403 response will be returned.

//
// Middleware for Both Page Router and App Router
// Location -> src/middleware.ts
//
import { NextRequest, NextResponse } from 'next/server';
import { middlewareGetSession } from '@/session/session';
import { isCsrfTokenValid, setCsrfTokenCookie } from '@/utils/csrf';

const PROTECTED_API_PATH_PREFIXES = ['/api/v1'];
const PROTECTED_PAGE_PATH_PREFIXES = ['/settings', '/account', '/profile'];
const ALL_PROTECTED_PATH_PREFIXES = [...PROTECTED_API_PATH_PREFIXES, ...PROTECTED_PAGE_PATH_PREFIXES];

function isProtectedRequest(req: NextRequest) {
  return ALL_PROTECTED_PATH_PREFIXES.some(prefix => req.nextUrl.pathname.startsWith(prefix));
}

/**
 * CSRF protection should only be applied to state-changing API endpoints, not page
 * navigations. Regular page navigation requests (GET requests) don't modify server
 * data and aren't vulnerable to CSRF attacks. Only check CSRF tokens for
 * POST/PUT/PATCH/DELETE methods to API routes where attackers could trick users into making
 * unauthorized state changes. This approach is more efficient and follows security
 * best practices.
 */
function isCsrfProtectedRequest(req: NextRequest) {
  return PROTECTED_API_PATH_PREFIXES.some(prefix => req.nextUrl.pathname.startsWith(prefix)) 
  && ['POST', 'PUT', 'PATCH', 'DELETE'].includes(req.method);
}

function unauthorizedResponse(req: NextRequest) {
  return req.nextUrl.pathname.startsWith('/api/')
    ? NextResponse.json({ statusText: 'Unauthorized' }, { status: 401 })
    : NextResponse.redirect('<replace-with-your-application-login-endpoint>');
}

export async function middleware(req: NextRequest) {
  const res = NextResponse.next();

  if (!isProtectedRequest(req)) {
    return res;
  }

  const session = await middlewareGetSession(req, res);
  const { csrfSecret, isAuthenticated } = session;
  if (!isAuthenticated) {
    return unauthorizedResponse(req);
  }
  
  /* ***** BEGIN NEW CSRF LOGIC ***** */
  
  if (isCsrfProtectedRequest(req)) {
    const isValidCsrf = await isCsrfTokenValid(req, csrfSecret);
    if (!isValidCsrf) {
      return NextResponse.json({ statusText: 'Forbidden' }, { status: 403 });
    }
  }
  
  // Always update the CSRF cookie whenever the session cookie is saved in order to 
  // keep the expiration times between the CSRF cookie and session cookie in sync.
  await setCsrfTokenCookie(csrfSecret, res);
  
  /* ***** END NEW CSRF LOGIC ***** */

  await session.save();

  return res;
}

export const config = {
  matcher: ['/((?!_next|fonts|examples|[\\w-]+\\.\\w+).*)'],
};


What’s Next

To complete our CSRF protection logic, we'll need to make adjustments to our existing auth endpoints.