inated layers: origin validation at the network edge, intent verification at the application layer, and strict session scoping for sensitive operations. We will implement this using Next.js App Router middleware, a double-submit token pattern, and a hardened route handler.
Step 1: Origin Validation Middleware
The first line of defense runs before any business logic. It rejects requests with missing or mismatched Origin headers. This is computationally cheap and eliminates the majority of cross-site forgery attempts.
// middleware.ts
import { NextResponse, type NextRequest } from 'next/server';
const ALLOWED_ORIGINS = new Set([
process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000',
process.env.NEXT_PUBLIC_ADMIN_URL,
]);
export function middleware(request: NextRequest) {
const isStateChanging = ['POST', 'PUT', 'PATCH', 'DELETE'].includes(request.method);
if (isStateChanging) {
const origin = request.headers.get('origin');
const referer = request.headers.get('referer');
if (!origin && !referer) {
return new NextResponse('Missing origin metadata', { status: 403 });
}
const validatedOrigin = origin || new URL(referer!).origin;
if (!ALLOWED_ORIGINS.has(validatedOrigin)) {
return new NextResponse('Origin validation failed', { status: 403 });
}
}
return NextResponse.next();
}
export const config = {
matcher: ['/api/admin/:path*', '/api/billing/:path*'],
};
Rationale: Middleware executes at the edge, before route handlers. Validating Origin (falling back to Referer for legacy clients) ensures the request originates from a trusted context. We restrict the matcher to sensitive paths to avoid performance overhead on public routes.
Step 2: Double-Submit Token Implementation
Origin validation fails against same-origin attacks. We need a mechanism that proves the client possesses a secret that cross-site pages cannot read. The double-submit pattern uses a cryptographically random token stored in a cookie and echoed in a custom header.
// lib/csrf.ts
import { randomBytes } from 'crypto';
const CSRF_COOKIE_NAME = '__csrf_proof';
const CSRF_HEADER_NAME = 'X-Intent-Token';
export function generateIntentToken(): string {
return randomBytes(32).toString('hex');
}
export function setCsrfCookie(response: Response, token: string): void {
response.cookies.set(CSRF_COOKIE_NAME, token, {
httpOnly: false, // Must be readable by client JS
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
path: '/',
maxAge: 60 * 60 * 24, // 24 hours
});
}
export function validateIntentToken(request: Request): boolean {
const cookieToken = request.cookies.get(CSRF_COOKIE_NAME)?.value;
const headerToken = request.headers.get(CSRF_HEADER_NAME);
if (!cookieToken || !headerToken) return false;
// Constant-time comparison to prevent timing attacks
return crypto.timingSafeEqual(
Buffer.from(cookieToken),
Buffer.from(headerToken)
);
}
Rationale: The token is stored in a non-httpOnly cookie so client-side JavaScript can read it. The client must send it back in a custom header (X-Intent-Token). Cross-site attackers cannot read the cookie due to same-origin policy, and they cannot set custom headers on cross-origin requests without triggering a CORS preflight, which will fail. This creates a cryptographic proof of intent.
Step 3: Hardened Route Handler
The endpoint combines origin validation (handled by middleware), token verification, and strict session scoping.
// app/api/admin/orders/[orderId]/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { validateIntentToken } from '@/lib/csrf';
import { getSessionTicket } from '@/lib/auth';
import { prisma } from '@/lib/db';
export async function PATCH(
request: NextRequest,
{ params }: { params: Promise<{ orderId: string }> }
) {
// 1. Verify request intent
if (!validateIntentToken(request)) {
return NextResponse.json({ error: 'Invalid request intent' }, { status: 403 });
}
// 2. Authenticate & authorize
const session = await getSessionTicket(request);
if (!session || session.role !== 'ADMIN') {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
// 3. Parse & validate payload
const { status } = await request.json();
const validStatuses = ['PENDING', 'PROCESSING', 'SHIPPED', 'DELIVERED', 'CANCELLED'];
if (!validStatuses.includes(status)) {
return NextResponse.json({ error: 'Invalid status value' }, { status: 400 });
}
// 4. Execute mutation
const { orderId } = await params;
const updatedOrder = await prisma.order.update({
where: { id: orderId },
data: { status },
});
return NextResponse.json({ success: true, order: updatedOrder });
}
Rationale: Validation occurs before database interaction. The intent check runs first to fail fast. Authentication is verified next. Payload validation prevents enum injection. The handler assumes middleware has already validated the origin, creating a clear separation of concerns. This structure scales cleanly across multiple administrative endpoints.
Pitfall Guide
1. Trusting Referer Over Origin
Explanation: Referer contains the full URL path and can be stripped by privacy extensions or corporate proxies. Origin contains only the scheme, host, and port, making it more reliable for validation.
Fix: Always prioritize Origin. Use Referer only as a fallback for legacy clients, and parse it carefully to extract the origin component.
2. Assuming httpOnly Prevents CSRF
Explanation: httpOnly blocks JavaScript from reading the cookie, which mitigates XSS token theft. CSRF does not require reading the cookie; it relies on the browser automatically attaching it to requests.
Fix: Treat httpOnly as an XSS mitigation, not a CSRF control. Always pair it with intent verification.
3. Overusing sameSite: "strict" Without Fallbacks
Explanation: strict blocks cookies on all cross-site requests, including legitimate top-level navigations from email links or SSO providers. This breaks password resets, OAuth callbacks, and deep-linking workflows.
Fix: Use strict only for highly sensitive operations (e.g., payment confirmation). For general sessions, use lax and rely on application-level CSRF tokens for protection.
4. Ignoring CORS Preflight Requirements
Explanation: Custom headers like X-Intent-Token trigger a CORS preflight (OPTIONS request) on cross-origin calls. If your server doesn't handle preflight correctly, legitimate cross-subdomain requests will fail silently.
Fix: Ensure your CORS configuration explicitly allows the custom header in Access-Control-Allow-Headers. Test cross-origin flows with browser dev tools to verify preflight behavior.
5. Static Token Generation Without Rotation
Explanation: Generating a single CSRF token at login and reusing it indefinitely increases the blast radius if the token is leaked via logs or client-side storage.
Fix: Rotate tokens periodically or regenerate them on sensitive actions. Implement token expiration and invalidate old tokens after a successful state change.
6. Validating CSRF After Business Logic Execution
Explanation: Running intent validation after database mutations or external API calls wastes resources and can cause partial state corruption if the request is later rejected.
Fix: Always validate authentication, authorization, and CSRF tokens before executing any business logic. Fail fast at the entry point.
7. Trusting credentials: "include" Across Subdomains
Explanation: Cookies scoped to a parent domain (e.g., .example.com) are sent to all subdomains. An attacker hosting a malicious page on evil.example.com can trigger requests to app.example.com with full cookie attachment.
Fix: Scope cookies to the exact subdomain (app.example.com), not the parent domain. Use separate session cookies for different subdomains when possible.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| Public-facing marketing site | sameSite: "lax" + Origin validation | Low risk, minimal overhead | Near zero |
| Admin dashboard with role-based access | Double-submit token + Strict cookie scoping | High privilege, requires intent proof | Low (middleware overhead) |
| Financial/payment endpoints | Double-submit token + sameSite: "strict" + Re-authentication | Maximum security, zero tolerance for forgery | Medium (UX friction) |
| Mobile app / SPA with token auth | Bearer token in Authorization header | Cookies not used, CSRF irrelevant | Zero |
| Legacy server-rendered app | Synchronizer token pattern in form hidden fields | Compatible with traditional POST flows | Low |
Configuration Template
// lib/security-config.ts
export const CSRF_CONFIG = {
cookieName: '__csrf_proof',
headerName: 'X-Intent-Token',
tokenLength: 32,
maxAge: 86400, // 24 hours
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax' as const,
};
export const ORIGIN_CONFIG = {
allowed: [
process.env.NEXT_PUBLIC_APP_URL!,
process.env.NEXT_PUBLIC_ADMIN_URL!,
].filter(Boolean),
fallbackToReferer: true,
};
// middleware.ts integration
import { NextResponse, type NextRequest } from 'next/server';
import { ORIGIN_CONFIG } from '@/lib/security-config';
export function middleware(request: NextRequest) {
const sensitiveMethods = ['POST', 'PUT', 'PATCH', 'DELETE'];
if (!sensitiveMethods.includes(request.method)) return NextResponse.next();
const origin = request.headers.get('origin');
const referer = request.headers.get('referer');
if (!origin && !referer) {
return new NextResponse('Origin required', { status: 403 });
}
const validated = origin || new URL(referer!).origin;
if (!ORIGIN_CONFIG.allowed.includes(validated)) {
return new NextResponse('Origin rejected', { status: 403 });
}
return NextResponse.next();
}
export const config = { matcher: ['/api/admin/:path*', '/api/billing/:path*'] };
Quick Start Guide
- Generate a CSRF utility module: Create
lib/csrf.ts with token generation, cookie setting, and validation functions using crypto.randomBytes and timingSafeEqual.
- Deploy origin middleware: Add
middleware.ts to your project root, configure the matcher for sensitive routes, and implement origin/Referer validation against an allowlist.
- Update frontend fetch calls: Modify your API client to read the CSRF cookie and attach it to the
X-Intent-Token header on all state-changing requests.
- Harden route handlers: Add
validateIntentToken(request) as the first check in your PATCH/POST handlers, followed by authentication and payload validation.
- Test in isolation: Spin up a separate origin (e.g.,
http://localhost:3001), attempt a cross-site request, and verify that the server returns 403 before any database logic executes.