Security is not something you add later. Here is a practical guide to hardening your Next.js application.
Security Headers
// next.config.ts
import type { NextConfig } from "next";
const securityHeaders = [
{
key: "X-DNS-Prefetch-Control",
value: "on",
},
{
key: "Strict-Transport-Security",
value: "max-age=63072000; includeSubDomains; preload",
},
{
key: "X-Frame-Options",
value: "SAMEORIGIN",
},
{
key: "X-Content-Type-Options",
value: "nosniff",
},
{
key: "Referrer-Policy",
value: "strict-origin-when-cross-origin",
},
{
key: "Permissions-Policy",
value: "camera=(), microphone=(), geolocation=(self)",
},
{
key: "X-XSS-Protection",
value: "1; mode=block",
},
];
const config: NextConfig = {
async headers() {
return [
{
source: "/(.*)",
headers: securityHeaders,
},
];
},
};
export default config;
Content Security Policy
// middleware.ts
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
export function middleware(request: NextRequest) {
const nonce = Buffer.from(crypto.randomUUID()).toString("base64");
const response = NextResponse.next();
const csp = [
`default-src 'self'`,
`script-src 'self' 'nonce-${nonce}' 'strict-dynamic'`,
`style-src 'self' 'unsafe-inline'`,
`img-src 'self' blob: data: https:`,
`font-src 'self'`,
`connect-src 'self' https://api.example.com`,
`frame-ancestors 'self'`,
`form-action 'self'`,
`base-uri 'self'`,
`upgrade-insecure-requests`,
].join("; ");
response.headers.set("Content-Security-Policy", csp);
response.headers.set("x-nonce", nonce);
return response;
}
CSRF Protection
// lib/csrf.ts
import { cookies } from "next/headers";
import { randomBytes, createHmac } from "crypto";
const SECRET = process.env.CSRF_SECRET!;
export async function generateCSRFToken(): Promise<string> {
const token = randomBytes(32).toString("hex");
const signature = createHmac("sha256", SECRET).update(token).digest("hex");
const signedToken = `${token}.${signature}`;
const cookieStore = await cookies();
cookieStore.set("csrf-token", signedToken, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "strict",
path: "/",
maxAge: 3600,
});
return signedToken;
}
export async function verifyCSRFToken(token: string): Promise<boolean> {
const cookieStore = await cookies();
const storedToken = cookieStore.get("csrf-token")?.value;
if (!storedToken || storedToken !== token) return false;
const [rawToken, signature] = token.split(".");
const expectedSignature = createHmac("sha256", SECRET)
.update(rawToken)
.digest("hex");
return signature === expectedSignature;
}
// Use in Server Components
import { generateCSRFToken } from "@/lib/csrf";
export default async function ContactPage() {
const csrfToken = await generateCSRFToken();
return (
<form action="/api/contact" method="POST">
<input type="hidden" name="_csrf" value={csrfToken} />
{/* Form fields */}
</form>
);
}
// Verify in API route
import { verifyCSRFToken } from "@/lib/csrf";
export async function POST(request: Request) {
const formData = await request.formData();
const csrfToken = formData.get("_csrf") as string;
const valid = await verifyCSRFToken(csrfToken);
if (!valid) {
return new Response("Invalid CSRF token", { status: 403 });
}
// Process form...
}
Input Validation and Sanitization
// lib/validation.ts
import { z } from "zod";
import DOMPurify from "isomorphic-dompurify";
// Strict schemas at API boundaries
export const ContactFormSchema = z.object({
name: z
.string()
.min(1, "Name is required")
.max(100, "Name too long")
.transform((val) => DOMPurify.sanitize(val)),
email: z
.string()
.email("Invalid email address")
.max(255, "Email too long"),
message: z
.string()
.min(10, "Message must be at least 10 characters")
.max(5000, "Message too long")
.transform((val) => DOMPurify.sanitize(val)),
phone: z
.string()
.regex(/^\+?[\d\s\-()]{7,20}$/, "Invalid phone number")
.optional()
.or(z.literal("")),
});
export type ContactFormData = z.infer<typeof ContactFormSchema>;
Authentication Middleware
// middleware.ts
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
const PROTECTED_ROUTES = ["/dashboard", "/admin", "/settings", "/api/admin"];
const AUTH_ROUTES = ["/login", "/register"];
export function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
const sessionToken = request.cookies.get("session-token")?.value;
// Redirect authenticated users away from auth pages
if (AUTH_ROUTES.some((route) => pathname.startsWith(route)) && sessionToken) {
return NextResponse.redirect(new URL("/dashboard", request.url));
}
// Protect dashboard routes
if (PROTECTED_ROUTES.some((route) => pathname.startsWith(route))) {
if (!sessionToken) {
const loginUrl = new URL("/login", request.url);
loginUrl.searchParams.set("redirect", pathname);
return NextResponse.redirect(loginUrl);
}
}
return NextResponse.next();
}
export const config = {
matcher: ["/dashboard/:path*", "/admin/:path*", "/settings/:path*", "/login", "/register", "/api/admin/:path*"],
};
Rate Limiting
// lib/rate-limit.ts
const rateLimitMap = new Map<string, { count: number; resetTime: number }>();
interface RateLimitOptions {
windowMs?: number;
maxRequests?: number;
}
export function rateLimit(
identifier: string,
options: RateLimitOptions = {}
): { success: boolean; remaining: number; resetIn: number } {
const { windowMs = 60_000, maxRequests = 10 } = options;
const now = Date.now();
const entry = rateLimitMap.get(identifier);
if (!entry || now > entry.resetTime) {
rateLimitMap.set(identifier, { count: 1, resetTime: now + windowMs });
return { success: true, remaining: maxRequests - 1, resetIn: windowMs };
}
if (entry.count >= maxRequests) {
return {
success: false,
remaining: 0,
resetIn: entry.resetTime - now,
};
}
entry.count++;
return {
success: true,
remaining: maxRequests - entry.count,
resetIn: entry.resetTime - now,
};
}
// Usage in API route
import { rateLimit } from "@/lib/rate-limit";
import { NextRequest, NextResponse } from "next/server";
export async function POST(request: NextRequest) {
const ip = request.headers.get("x-forwarded-for") ?? "unknown";
const { success, remaining, resetIn } = rateLimit(ip, {
windowMs: 60_000,
maxRequests: 5,
});
if (!success) {
return NextResponse.json(
{ error: "Too many requests" },
{
status: 429,
headers: {
"Retry-After": String(Math.ceil(resetIn / 1000)),
"X-RateLimit-Remaining": "0",
},
}
);
}
// Handle request...
const response = NextResponse.json({ ok: true });
response.headers.set("X-RateLimit-Remaining", String(remaining));
return response;
}
Secure Cookie Configuration
// lib/session.ts
import { cookies } from "next/headers";
import { SignJWT, jwtVerify } from "jose";
const secret = new TextEncoder().encode(process.env.JWT_SECRET!);
export async function createSession(userId: string) {
const token = await new SignJWT({ userId })
.setProtectedHeader({ alg: "HS256" })
.setExpirationTime("7d")
.setIssuedAt()
.sign(secret);
const cookieStore = await cookies();
cookieStore.set("session-token", token, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
path: "/",
maxAge: 60 * 60 * 24 * 7, // 7 days
});
}
export async function getSession(): Promise<{ userId: string } | null> {
const cookieStore = await cookies();
const token = cookieStore.get("session-token")?.value;
if (!token) return null;
try {
const { payload } = await jwtVerify(token, secret);
return { userId: payload.userId as string };
} catch {
return null;
}
}
Security Checklist
- Set all security headers including CSP
- Use CSRF tokens on all state-changing forms
- Validate and sanitize all input at API boundaries
- Use parameterized queries (never string concatenation for SQL)
- Set HttpOnly, Secure, SameSite on all cookies
- Implement rate limiting on authentication and form endpoints
- Use HTTPS everywhere in production
- Keep dependencies updated and audit regularly
- Never expose stack traces or internal errors to users
- Log security events for monitoring
Need a Security Audit?
We review and harden web applications against common vulnerabilities. Contact us for a security consultation.