CSRF Protection
beginnerCross-Site Request Forgery protection for form submissions using Web Crypto API.
csrfsecurityformstokens
Tested on⬢20▲16⚛19TS5.9
$ bunx sinew add security/csrf-protectionInteractive demo coming soon
1The Problem
Forms and state-changing requests are vulnerable to CSRF attacks where:
- Malicious sites trick users into making unwanted requests
- Requests appear legitimate (include cookies)
- No way to verify request origin
2The Solution
Use the double-submit cookie pattern: a random token is stored in an httpOnly, SameSite=strict cookie and echoed back in a form field or header. The server compares the two with a constant-time check. No shared signing secret is required.
3Files
lib/csrf/tokens.ts
lib/csrf/tokens.tsTypeScript
import { cookies } from "next/headers";
const CSRF_TOKEN_NAME = "csrf_token"; // cookie name
const CSRF_HEADER_NAME = "x-csrf-token"; // request header name
const CSRF_FIELD_NAME = "_csrf"; // form / JSON body field name
const TOKEN_LENGTH = 32; // 256 bits
async function generateToken(): Promise<string> {
const buffer = new Uint8Array(TOKEN_LENGTH);
crypto.getRandomValues(buffer);
return Array.from(buffer)
.map((b) => b.toString(16).padStart(2, "0"))
.join("");
}
export async function getCSRFToken(): Promise<string> {
const cookieStore = await cookies();
return cookieStore.get(CSRF_TOKEN_NAME)?.value ?? (await generateToken());
}
export async function setCSRFCookie(token: string): Promise<void> {
const cookieStore = await cookies();
cookieStore.set(CSRF_TOKEN_NAME, token, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "strict",
path: "/",
maxAge: 60 * 60 * 24, // 24 hours
});
}
async function validateAgainstCookie(
request: Request,
cookieToken: string | undefined
): Promise<boolean> {
if (!cookieToken) return false;
const headerToken = request.headers.get(CSRF_HEADER_NAME);
if (headerToken) return timingSafeEqual(cookieToken, headerToken);
const contentType = request.headers.get("content-type");
try {
if (contentType?.includes("application/x-www-form-urlencoded")) {
const formData = await request.clone().formData();
const bodyToken = formData.get(CSRF_FIELD_NAME)?.toString();
if (bodyToken) return timingSafeEqual(cookieToken, bodyToken);
}
if (contentType?.includes("application/json")) {
const body = (await request.clone().json()) as Record<string, unknown>;
const bodyToken = body[CSRF_FIELD_NAME];
if (typeof bodyToken === "string") return timingSafeEqual(cookieToken, bodyToken);
}
} catch {
// Malformed/empty body: fail validation instead of throwing.
return false;
}
return false;
}
// For Route Handlers / Server Actions (cookie read via next/headers).
export async function validateCSRFToken(request: Request): Promise<boolean> {
const cookieStore = await cookies();
return validateAgainstCookie(request, cookieStore.get(CSRF_TOKEN_NAME)?.value);
}
// For proxy.ts, where the cookie must come from request.cookies.
export async function validateCSRFTokenEdge(
request: Request,
cookieToken: string | undefined
): Promise<boolean> {
return validateAgainstCookie(request, cookieToken);
}
// Tokens are fixed-length hex, so the length check leaks nothing useful.
function timingSafeEqual(a: string, b: string): boolean {
if (a.length !== b.length) return false;
const enc = new TextEncoder();
const aBytes = enc.encode(a);
const bBytes = enc.encode(b);
let result = 0;
for (let i = 0; i < aBytes.length; i++) result |= aBytes[i] ^ bBytes[i];
return result === 0;
}components/csrf-input.tsx
components/csrf-input.tsxTSX
"use client";
import { useEffect, useState } from "react";
export function CSRFInput({ name = "_csrf" }: { name?: string }) {
const [token, setToken] = useState("");
useEffect(() => {
fetch("/api/csrf")
.then((res) => res.json())
.then((data) => setToken(data.token));
}, []);
return <input type="hidden" name={name} value={token} />;
}app/api/csrf/route.ts
app/api/csrf/route.tsTypeScript
import { NextResponse } from "next/server";
import { getCSRFToken, setCSRFCookie } from "@/lib/csrf/tokens";
export async function GET() {
const token = await getCSRFToken();
await setCSRFCookie(token);
return NextResponse.json({ token });
}4Configuration
No environment variables are required. The double-submit pattern relies on the httpOnly, SameSite=strict cookie set by setCSRFCookie, so HTTPS in production is recommended (the cookie's secure flag is set automatically).
5Usage
In Forms
import { CSRFInput } from "@/components/csrf-input";
export function ContactForm() {
return (
<form action="/api/contact" method="POST">
<CSRFInput />
<input name="email" type="email" required />
<button type="submit">Submit</button>
</form>
);
}TSX
Validate in a Route Handler
import { validateCSRFToken } from "@/lib/csrf/tokens";
export async function POST(req: Request) {
if (!(await validateCSRFToken(req))) {
return Response.json({ error: "Invalid CSRF token" }, { status: 403 });
}
// Process request...
}TypeScript
Validate in proxy.ts
In Next.js 16 the middleware file is named proxy.ts. Read the cookie via request.cookies: