Add CSRF Protection to Backend
Protect your app by handling CSRF tokens in auth endpoints and middleware in the backend.
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 toStrict
will ensure that the browser won't send the cookie for cross-site requests, eliminating the threat of CSRF. However, for looser SameSite
settings or 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 Synchronizer Token pattern.
Below, we'll show how to use the Synchronizer Token pattern to prevent CSRF attacks. For a high-level overview of how this pattern works, please refer to the following documentation.
Add CSRF Utility Functions
First, create a couple of utility functions somewhere in your backend for use within the Callback Endpoint and the auth middleware.
//
// Location -> src/utils/csrf.ts
//
import { NextApiResponse } from 'next';
import { NextResponse } from 'next/server';
/* Generates a new CSRF token. */
export function createCsrfToken(): string {
const array = new Uint8Array(32);
crypto.getRandomValues(array);
return Array.from(array)
.map((b) => b.toString(16).padStart(2, '0'))
.join('');
}
/* Updates the CSRF cookie with the CSRF token. */
export async function updateCsrfCookie(csrfToken: string, res: Response | NextApiResponse) {
// IMPORTANT: Only set this to false if your server isn't using HTTPS.
const isSecure = process.env.NODE_ENV === 'production';
// Omit "HttpOnly" so that your frontend JavaScript code can access the value. "SameSite"
// should match the settings of your session cookie. "Max-Age" should match your session
// cookie expiration.
const cookieValue = `${'CSRF-TOKEN'}=${csrfToken}; Path=/; SameSite=Strict; Max-Age=1800${isSecure ? '; Secure' : ''}`;
if (res instanceof Response) {
// For Middleware's Edge runtime
res.headers.append('Set-Cookie', cookieValue);
} else if ('setHeader' in res) {
// For API Route Handler's Node runtime
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');
}
}
//
// Location -> src/utils/csrf.ts
//
import { NextResponse } from 'next/server';
/* Generates a new CSRF token. */
export function createCsrfToken(): string {
const array = new Uint8Array(32);
crypto.getRandomValues(array);
return Array.from(array)
.map((b) => b.toString(16).padStart(2, '0'))
.join('');
}
/* Updates the CSRF cookie with the CSRF token. */
export async function updateCsrfCookie(csrfToken: string, res: NextResponse) {
// IMPORTANT: Only set this to false if your server isn't using HTTPS.
const isSecure = process.env.NODE_ENV === 'production';
// Omit "HttpOnly" so that your frontend JavaScript code can access the value. "SameSite"
// should match the settings of your session cookie. "Max-Age" should match your session
// cookie expiration.
const cookieValue = `${'CSRF-TOKEN'}=${csrfToken}; Path=/; SameSite=Strict; Max-Age=1800${isSecure ? '; Secure' : ''}`;
res.headers.append('Set-Cookie', cookieValue);
}
Update Callback Endpoint
Next, your Callback Endpoint needs to be updated to create a CSRF token. Once the CSRF token is generated, it will be stored in a CSRF cookie so that the frontend JavaScript code can read it. In addition, the CSRF token will be stored in the session cookie so the CSRF middleware can access and validate it.
//
// Location -> src/pages/api/auth/callback.ts
//
import type { NextApiRequest, NextApiResponse } from 'next';
import { CallbackResultType, PageRouterCallbackResult } from '@wristband/nextjs-auth';
import { wristbandAuth } from '@/wristband-auth';
import { getSession } from '@/session/session';
import { createCsrfToken, updateCsrfCookie } from '@/utils/csrf';
// Callback Endpoint
export default async function handleCallback(req: NextApiRequest, res: NextApiResponse) {
try {
const callbackResult: PageRouterCallbackResult = await wristbandAuth.pageRouter.callback(req, res);
const { callbackData, redirectUrl, type } = callbackResult;
if (type === CallbackResultType.REDIRECT_REQUIRED) {
return res.redirect(redirectUrl);
}
const session = await getSession(req, res);
session.isAuthenticated = true;
session.accessToken = callbackData.accessToken;
session.expiresAt = Date.now() + callbackData.expiresIn * 1000;
session.refreshToken = callbackData.refreshToken;
session.userId = callbackData.userinfo.sub;
session.tenantId = callbackData.userinfo.tnt_id;
session.tenantDomainName = callbackData.tenantDomainName;
session.tenantCustomDomain = callbackData.tenantCustomDomain || undefined;
/* ***** BEGIN NEW CSRF LOGIC ***** */
const csrfToken = createCsrfToken();
session.csrfToken = csrfToken;
await updateCsrfCookie(csrfToken, res);
/* ***** END NEW CSRF LOGIC ***** */
await session.save();
return res.redirect(callbackData!.returnUrl || '<replace_with_a_default_return_url>');
} catch (error) {
console.error(error);
return res.status(500).json({ error: "Internal Server Error" });
}
}
//
// Location -> src/app/api/auth/callback/route.ts
//
import { NextRequest } from 'next/server';
import { AppRouterCallbackResult, CallbackResultType } from '@wristband/nextjs-auth';
import { wristbandAuth } from '@/wristband-auth';
import { getSession } from '@/session/session';
import { createCsrfToken, updateCsrfCookie } from '@/utils/csrf';
// Callback Endpoint
export async function GET(req: NextRequest) {
try {
const callbackResult: AppRouterCallbackResult = await wristbandAuth.appRouter.callback(req);
const { callbackData, redirectUrl, type } = callbackResult;
if (type === CallbackResultType.REDIRECT_REQUIRED) {
return await wristbandAuth.appRouter.createCallbackResponse(req, redirectUrl);
}
const appUrl = callbackData!.returnUrl || '<replace_with_a_default_return_url>';
const callbackResponse = await wristbandAuth.appRouter.createCallbackResponse(req, appUrl);
const session = await getSession();
session.isAuthenticated = true;
session.accessToken = callbackData.accessToken;
session.expiresAt = Date.now() + callbackData.expiresIn * 1000;
session.refreshToken = callbackData.refreshToken;
session.userId = callbackData.userinfo.sub;
session.tenantId = callbackData.userinfo.tnt_id;
session.tenantDomainName = callbackData.tenantDomainName;
session.tenantCustomDomain = callbackData.tenantCustomDomain || undefined;
/* ***** BEGIN NEW CSRF LOGIC ***** */
const csrfToken = createCsrfToken();
session.csrfToken = csrfToken;
await updateCsrfCookie(csrfToken, callbackResponse);
/* ***** END NEW CSRF LOGIC ***** */
await session.save();
return callbackResponse;
} catch (error) {
console.error(error);
return Response.json({ error: "Internal Server Error" }, { status: 500 });
}
}
Update Logout Endpoint
Now, update your Logout Endpoint to delete the CSRF cookie before redirecting to the Wristband Logout Endpoint.
//
// Location -> src/pages/api/auth/logout.ts
//
import type { NextApiRequest, NextApiResponse } from 'next';
import { wristbandAuth } from '@/wristband-auth';
import { getSession } from '@/session/session';
// Logout endpoint
export default async function handleLogout(req: NextApiRequest, res: NextApiResponse) {
try {
const session = await getSession(req, res);
const { refreshToken, tenantCustomDomain, tenantDomainName } = session;
const logoutConfig = { refreshToken, tenantCustomDomain, tenantDomainName };
res.setHeader('Set-Cookie', [`session=; Max-Age=0; Path=/`]);
session.destroy();
/* ***** BEGIN NEW CSRF LOGIC ***** */
res.setHeader('Set-Cookie', [`CSRF-TOKEN=; Max-Age=0; Path=/`]);
/* ***** END NEW CSRF LOGIC ***** */
const logoutUrl = await wristbandAuth.pageRouter.logout(req, res, logoutConfig);
res.redirect(logoutUrl);
} catch (error) {
console.error(error);
res.status(500).json({ error: "Internal Server Error" });
}
}
//
// Location -> src/app/api/auth/logout/route.ts
//
import type { NextRequest } from 'next/server';
import { cookies } from 'next/headers';
import { wristbandAuth } from '@/wristband-auth';
import { getSession } from '@/session/session';
// Logout endpoint
export async function GET(req: NextRequest) {
try {
const session = await getSession();
const { refreshToken, tenantCustomDomain, tenantDomainName } = session;
const logoutConfig = { refreshToken, tenantCustomDomain, tenantDomainName };
const cookieStore = await cookies();
cookieStore.delete('session');
session.destroy();
/* ***** BEGIN NEW CSRF LOGIC ***** */
cookieStore.delete('CSRF-TOKEN');
/* ***** END NEW CSRF LOGIC ***** */
return await wristbandAuth.appRouter.logout(req, logoutConfig);
} catch (error) {
console.error(error);
return Response.json({ error: "Internal Server Error" }, { status: 500 });
}
}
Add CSRF Validation to Auth Middleware
Finally, update the auth middleware to check whether the CSRF token in the request header matches the CSRF token that comes from 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 { updateCsrfCookie } 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));
}
function createErrorResponse(req: NextRequest, status: number) {
return req.nextUrl.pathname.startsWith('/api/')
? new NextResponse(null, { status })
: NextResponse.redirect('<replace-with-your-application-login-endpoint>');
}
// CSRF protection should only be applied to API endpoints, not page navigations.
function isCsrfValid(req: NextRequest, csrfToken: string = '') {
if (PROTECTED_PAGE_PATH_PREFIXES.some((prefix) => req.nextUrl.pathname.startsWith(prefix))) {
return true;
}
return csrfToken && csrfToken === req.headers.get(CSRF_TOKEN_HEADER_NAME);
}
export async function middleware(req: NextRequest) {
const res = NextResponse.next();
if (!isProtectedRequest(req)) {
return res;
}
const session = await middlewareGetSession(req, res);
const { csrfToken, isAuthenticated } = session;
if (!isAuthenticated) {
return createErrorResponse(req, 401);
}
/* ***** BEGIN NEW CSRF LOGIC ***** */
if (!isCsrfValid(req, csrfToken)) {
return createErrorResponse(req, 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 updateCsrfCookie(csrfToken, res);
/* ***** END NEW CSRF LOGIC ***** */
await session.save();
return res;
}
export const config = {
matcher: ['/((?!_next|fonts|examples|[\\w-]+\\.\\w+).*)'],
};
Updated 5 days ago
Next, let's enhance the frontend to be able to pass a CSRF request header when making API calls to your backend.