Next.js Security
Introduction to Next.js Security
Security is a critical aspect of web application development. Next.js provides built-in security features, but developers must implement additional measures to protect applications from common vulnerabilities like CSRF, XSS, SQL injection, and DDoS attacks.
Security Mindset: Security is not a feature you add at the end—it must be integrated into every layer of your application from the beginning.
Cross-Site Request Forgery (CSRF) Protection
CSRF attacks trick authenticated users into executing unwanted actions. Next.js doesn't include built-in CSRF protection, so you must implement it manually.
Implementing CSRF Tokens
// lib/csrf.ts
import { randomBytes } from 'crypto';
import { cookies } from 'next/headers';
export function generateCSRFToken(): string {
return randomBytes(32).toString('hex');
}
export function setCSRFToken(): string {
const token = generateCSRFToken();
cookies().set('csrf-token', token, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict',
maxAge: 60 * 60 * 24 // 24 hours
});
return token;
}
export function verifyCSRFToken(token: string): boolean {
const storedToken = cookies().get('csrf-token')?.value;
return storedToken === token;
}
CSRF Middleware
// middleware.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
const PROTECTED_METHODS = ['POST', 'PUT', 'DELETE', 'PATCH'];
export function middleware(request: NextRequest) {
// Skip CSRF check for safe methods and API routes from same origin
if (!PROTECTED_METHODS.includes(request.method)) {
return NextResponse.next();
}
const csrfToken = request.headers.get('x-csrf-token');
const csrfCookie = request.cookies.get('csrf-token')?.value;
if (!csrfToken || !csrfCookie || csrfToken !== csrfCookie) {
return NextResponse.json(
{ error: 'Invalid CSRF token' },
{ status: 403 }
);
}
return NextResponse.next();
}
export const config = {
matcher: ['/api/:path*', '/actions/:path*']
};
Using CSRF in Forms
// app/contact/page.tsx
import { setCSRFToken } from '@/lib/csrf';
export default function ContactPage() {
const csrfToken = setCSRFToken();
return (
<form action="/api/contact" method="POST">
<input type="hidden" name="csrf_token" value={csrfToken} />
<input type="text" name="name" placeholder="Your name" />
<textarea name="message" placeholder="Your message"></textarea>
<button type="submit">Send</button>
</form>
);
}
// Client-side fetch with CSRF
'use client';
async function submitForm(data: FormData) {
const csrfToken = document.cookie
.split('; ')
.find(row => row.startsWith('csrf-token='))
?.split('=')[1];
const response = await fetch('/api/contact', {
method: 'POST',
headers: {
'X-CSRF-Token': csrfToken || ''
},
body: data
});
return response.json();
}
Tip: For Server Actions in Next.js 14+, use the useFormState hook with hidden CSRF tokens or implement origin verification.
Cross-Site Scripting (XSS) Prevention
XSS attacks inject malicious scripts into your application. Next.js automatically escapes content, but you must be careful with certain scenarios.
Dangerous Patterns to Avoid
// ❌ DANGEROUS: Using dangerouslySetInnerHTML
function BlogPost({ content }: { content: string }) {
return <div dangerouslySetInnerHTML={{ __html: content }} />;
}
// ❌ DANGEROUS: Direct script injection
function Analytics({ code }: { code: string }) {
return <script>{code}</script>;
}
Safe Content Rendering
// ✅ SAFE: Use a sanitization library
import DOMPurify from 'isomorphic-dompurify';
function BlogPost({ content }: { content: string }) {
const sanitizedContent = DOMPurify.sanitize(content, {
ALLOWED_TAGS: ['p', 'b', 'i', 'em', 'strong', 'a', 'h1', 'h2', 'h3'],
ALLOWED_ATTR: ['href', 'target']
});
return <div dangerouslySetInnerHTML={{ __html: sanitizedContent }} />;
}
// ✅ SAFE: Use markdown parser with sanitization
import { remark } from 'remark';
import html from 'remark-html';
import sanitizeHtml from 'sanitize-html';
async function parseMarkdown(markdown: string) {
const result = await remark().use(html).process(markdown);
return sanitizeHtml(result.toString());
}
Input Validation and Sanitization
// lib/validation.ts
import { z } from 'zod';
export const userInputSchema = z.object({
name: z.string().min(2).max(50).regex(/^[a-zA-Z\s]+$/),
email: z.string().email(),
message: z.string().min(10).max(1000),
website: z.string().url().optional()
});
// Usage in API route
export async function POST(request: Request) {
const body = await request.json();
try {
const validatedData = userInputSchema.parse(body);
// Process validated data
return Response.json({ success: true });
} catch (error) {
return Response.json(
{ error: 'Invalid input' },
{ status: 400 }
);
}
}
Warning: Never trust user input. Always validate and sanitize data on the server side, even if you have client-side validation.
Content Security Policy (CSP)
CSP is a powerful security feature that helps prevent XSS attacks by controlling which resources can be loaded.
Implementing CSP Headers
// next.config.js
const securityHeaders = [
{
key: 'Content-Security-Policy',
value: `
default-src 'self';
script-src 'self' 'unsafe-eval' 'unsafe-inline' https://cdn.jsdelivr.net;
style-src 'self' 'unsafe-inline' https://fonts.googleapis.com;
img-src 'self' data: https://images.unsplash.com;
font-src 'self' https://fonts.gstatic.com;
connect-src 'self' https://api.example.com;
frame-ancestors 'none';
base-uri 'self';
form-action 'self';
`.replace(/\s{2,}/g, ' ').trim()
}
];
module.exports = {
async headers() {
return [
{
source: '/:path*',
headers: securityHeaders
}
];
}
};
Nonce-Based CSP (More Secure)
// middleware.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
import { randomBytes } from 'crypto';
export function middleware(request: NextRequest) {
const nonce = randomBytes(16).toString('base64');
const cspHeader = `
default-src 'self';
script-src 'self' 'nonce-${nonce}' 'strict-dynamic';
style-src 'self' 'nonce-${nonce}';
img-src 'self' data: https:;
font-src 'self';
object-src 'none';
base-uri 'self';
form-action 'self';
frame-ancestors 'none';
upgrade-insecure-requests;
`.replace(/\s{2,}/g, ' ').trim();
const response = NextResponse.next();
response.headers.set('Content-Security-Policy', cspHeader);
response.headers.set('X-Nonce', nonce);
return response;
}
// app/layout.tsx
import { headers } from 'next/headers';
export default function RootLayout({ children }) {
const nonce = headers().get('X-Nonce') || '';
return (
<html>
<head>
<script nonce={nonce} src="/analytics.js"></script>
</head>
<body>{children}</body>
</html>
);
}
Rate Limiting
Protect your APIs from abuse and DDoS attacks with rate limiting.
Simple In-Memory Rate Limiter
// lib/rate-limiter.ts
const rateLimit = new Map<string, { count: number; resetTime: number }>();
export function checkRateLimit(
identifier: string,
limit: number = 10,
windowMs: number = 60000
): { allowed: boolean; remaining: number } {
const now = Date.now();
const userLimit = rateLimit.get(identifier);
if (!userLimit || now > userLimit.resetTime) {
rateLimit.set(identifier, {
count: 1,
resetTime: now + windowMs
});
return { allowed: true, remaining: limit - 1 };
}
if (userLimit.count >= limit) {
return { allowed: false, remaining: 0 };
}
userLimit.count++;
return { allowed: true, remaining: limit - userLimit.count };
}
// app/api/login/route.ts
import { checkRateLimit } from '@/lib/rate-limiter';
export async function POST(request: Request) {
const ip = request.headers.get('x-forwarded-for') || 'unknown';
const { allowed, remaining } = checkRateLimit(ip, 5, 60000); // 5 requests per minute
if (!allowed) {
return Response.json(
{ error: 'Too many requests. Please try again later.' },
{
status: 429,
headers: {
'Retry-After': '60',
'X-RateLimit-Remaining': '0'
}
}
);
}
// Process login
return Response.json({ success: true }, {
headers: {
'X-RateLimit-Remaining': remaining.toString()
}
});
}
Redis-Based Rate Limiter (Production)
// lib/redis-rate-limiter.ts
import Redis from 'ioredis';
const redis = new Redis(process.env.REDIS_URL!);
export async function checkRateLimitRedis(
key: string,
limit: number,
windowSeconds: number
): Promise<{ allowed: boolean; remaining: number }> {
const multi = redis.multi();
multi.incr(key);
multi.expire(key, windowSeconds);
const results = await multi.exec();
const count = results?.[0]?.[1] as number;
if (count > limit) {
return { allowed: false, remaining: 0 };
}
return { allowed: true, remaining: limit - count };
}
Tip: Implement different rate limits for different endpoints. Authentication endpoints should have stricter limits than general API endpoints.
Security Headers
Essential Security Headers
// next.config.js
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: 'X-XSS-Protection',
value: '1; mode=block'
},
{
key: 'Referrer-Policy',
value: 'strict-origin-when-cross-origin'
},
{
key: 'Permissions-Policy',
value: 'camera=(), microphone=(), geolocation=()'
}
];
module.exports = {
async headers() {
return [
{
source: '/:path*',
headers: securityHeaders
}
];
}
};
SQL Injection Prevention
Use parameterized queries and ORMs to prevent SQL injection attacks.
Safe Database Queries
// ❌ DANGEROUS: String interpolation
async function getUser(email: string) {
const query = `SELECT * FROM users WHERE email = '${email}'`;
return db.execute(query);
}
// ✅ SAFE: Parameterized query
async function getUser(email: string) {
return db.query('SELECT * FROM users WHERE email = $1', [email]);
}
// ✅ SAFE: Using Prisma ORM
async function getUser(email: string) {
return prisma.user.findUnique({
where: { email }
});
}
Authentication Best Practices
Secure Password Hashing
// lib/password.ts
import bcrypt from 'bcrypt';
const SALT_ROUNDS = 12;
export async function hashPassword(password: string): Promise<string> {
return bcrypt.hash(password, SALT_ROUNDS);
}
export async function verifyPassword(
password: string,
hash: string
): Promise<boolean> {
return bcrypt.compare(password, hash);
}
Secure Session Management
// lib/session.ts
import { SignJWT, jwtVerify } from 'jose';
import { cookies } from 'next/headers';
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('24h')
.setIssuedAt()
.sign(secret);
cookies().set('session', token, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict',
maxAge: 60 * 60 * 24 // 24 hours
});
}
export async function verifySession() {
const token = cookies().get('session')?.value;
if (!token) return null;
try {
const { payload } = await jwtVerify(token, secret);
return payload.userId as string;
} catch {
return null;
}
}
Exercise: Build a Secure Contact Form
Create a contact form with:
- CSRF protection with tokens
- Input validation using Zod
- XSS prevention with sanitization
- Rate limiting (5 requests per 10 minutes per IP)
- CSP headers with nonce for inline scripts
- Proper error handling without exposing sensitive info
- Email notification with sanitized content
Bonus: Add reCAPTCHA v3 for bot protection and implement honeypot fields.
Environment Variables Security
Safe Environment Variable Usage
// ❌ DANGEROUS: Exposing secrets to client
// next.config.js
module.exports = {
env: {
DATABASE_URL: process.env.DATABASE_URL // Exposed to client!
}
};
// ✅ SAFE: Only expose public variables
module.exports = {
env: {
NEXT_PUBLIC_API_URL: process.env.NEXT_PUBLIC_API_URL
}
};
// ✅ SAFE: Use server-only environment variables
// Server Components can access all env vars directly
export default async function Page() {
const data = await fetch(process.env.INTERNAL_API_URL);
return <div>{/* render */}</div>;
}
Security Checklist
- ✅ HTTPS enforced in production
- ✅ CSRF protection on state-changing requests
- ✅ Input validation and sanitization
- ✅ CSP headers configured
- ✅ Rate limiting on sensitive endpoints
- ✅ Secure session management
- ✅ Password hashing with bcrypt/argon2
- ✅ SQL injection prevention with ORMs
- ✅ XSS prevention with proper escaping
- ✅ Security headers configured
- ✅ Environment variables secured
- ✅ Dependencies regularly updated
- ✅ Security audits with
npm audit
Pro Tip: Use tools like npm audit, Snyk, and OWASP ZAP to regularly scan for vulnerabilities in your application and dependencies.