Security
Complete guide to security best practices for Next.js applications deployed on Vercel.
Security is fundamental in modern web applications. This guide provides practical examples and proven patterns to protect your Next.js application deployed on Vercel, based on official documentation and industry best practices.
CSP is essential to protect against XSS attacks, clickjacking, and code injection. Implement CSP with nonces in Next.js for enhanced security.
import { NextResponse } from 'next/server';export function proxy(request) { // Generate unique nonce for each request const nonce = Buffer.from(crypto.randomUUID()).toString('base64'); const isDev = process.env.NODE_ENV === 'development'; const cspHeader = ` default-src 'self'; script-src 'self' 'nonce-${nonce}' 'strict-dynamic' ${isDev ? "'unsafe-eval'" : ''}; style-src 'self' ${isDev ? "'unsafe-inline'" : `'nonce-${nonce}'`}; img-src 'self' blob: data: https:; font-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'; upgrade-insecure-requests; `; const contentSecurityPolicyHeaderValue = cspHeader .replace(/\s{2,}/g, ' ') .trim(); const requestHeaders = new Headers(request.headers); requestHeaders.set('x-nonce', nonce); requestHeaders.set('Content-Security-Policy', contentSecurityPolicyHeaderValue); const response = NextResponse.next({ request: { headers: requestHeaders }, }); response.headers.set('Content-Security-Policy', contentSecurityPolicyHeaderValue); return response;}export const config = { matcher: [ { source: '/((?!api|_next/static|_next/image|favicon.ico).*)', missing: [ { type: 'header', key: 'next-router-prefetch' }, { type: 'header', key: 'purpose', value: 'prefetch' }, ], }, ],};import { NextResponse } from 'next/server';export function proxy(request) { // Generate unique nonce for each request const nonce = Buffer.from(crypto.randomUUID()).toString('base64'); const isDev = process.env.NODE_ENV === 'development'; const cspHeader = ` default-src 'self'; script-src 'self' 'nonce-${nonce}' 'strict-dynamic' ${isDev ? "'unsafe-eval'" : ''}; style-src 'self' ${isDev ? "'unsafe-inline'" : `'nonce-${nonce}'`}; img-src 'self' blob: data: https:; font-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'; upgrade-insecure-requests; `; const contentSecurityPolicyHeaderValue = cspHeader .replace(/\s{2,}/g, ' ') .trim(); const requestHeaders = new Headers(request.headers); requestHeaders.set('x-nonce', nonce); requestHeaders.set('Content-Security-Policy', contentSecurityPolicyHeaderValue); const response = NextResponse.next({ request: { headers: requestHeaders }, }); response.headers.set('Content-Security-Policy', contentSecurityPolicyHeaderValue); return response;}export const config = { matcher: [ { source: '/((?!api|_next/static|_next/image|favicon.ico).*)', missing: [ { type: 'header', key: 'next-router-prefetch' }, { type: 'header', key: 'purpose', value: 'prefetch' }, ], }, ],};Handle secrets and credentials securely with environment variables. Vercel offers encrypted sensitive variables that are never exposed.
# Database credentials - NEVER commit to gitDATABASE_URL="postgresql://user:password@localhost:5432/mydb"# API Keys - Use Vercel's sensitive environment variablesSTRIPE_SECRET_KEY="sk_live_..."OPENAI_API_KEY="sk-..."# Public variables (can be exposed to browser)NEXT_PUBLIC_API_URL="https://api.example.com"# Auth secrets - Generate with: openssl rand -base64 32NEXTAUTH_SECRET="your-secret-here"NEXTAUTH_URL="https://yourdomain.com"# Vercel automatically provides these:# VERCEL_URL - Deployment URL# VERCEL_ENV - Environment (production, preview, development)# Database credentials - NEVER commit to gitDATABASE_URL="postgresql://user:password@localhost:5432/mydb"# API Keys - Use Vercel's sensitive environment variablesSTRIPE_SECRET_KEY="sk_live_..."OPENAI_API_KEY="sk-..."# Public variables (can be exposed to browser)NEXT_PUBLIC_API_URL="https://api.example.com"# Auth secrets - Generate with: openssl rand -base64 32NEXTAUTH_SECRET="your-secret-here"NEXTAUTH_URL="https://yourdomain.com"# Vercel automatically provides these:# VERCEL_URL - Deployment URL# VERCEL_ENV - Environment (production, preview, development)Protect routes and APIs with Proxy (formerly Middleware in Next.js 15). Verify authentication, authorization, and add security headers on every request.
import { NextResponse } from 'next/server';import { getToken } from 'next-auth/jwt';export async function proxy(request) { const token = await getToken({ req: request, secret: process.env.NEXTAUTH_SECRET }); // Protect API routes if (request.nextUrl.pathname.startsWith('/api/protected')) { if (!token) { return NextResponse.json( { error: 'Unauthorized' }, { status: 401 } ); } } // Protect admin routes if (request.nextUrl.pathname.startsWith('/admin')) { if (!token || token.role !== 'admin') { return NextResponse.redirect(new URL('/login', request.url)); } } // Add security headers const response = NextResponse.next(); response.headers.set('X-Frame-Options', 'DENY'); response.headers.set('X-Content-Type-Options', 'nosniff'); response.headers.set('Referrer-Policy', 'strict-origin-when-cross-origin'); response.headers.set('Permissions-Policy', 'geolocation=(), microphone=(), camera=()'); return response;}export const config = { matcher: ['/api/protected/:path*', '/admin/:path*'],};import { NextResponse } from 'next/server';import { getToken } from 'next-auth/jwt';export async function proxy(request) { const token = await getToken({ req: request, secret: process.env.NEXTAUTH_SECRET }); // Protect API routes if (request.nextUrl.pathname.startsWith('/api/protected')) { if (!token) { return NextResponse.json( { error: 'Unauthorized' }, { status: 401 } ); } } // Protect admin routes if (request.nextUrl.pathname.startsWith('/admin')) { if (!token || token.role !== 'admin') { return NextResponse.redirect(new URL('/login', request.url)); } } // Add security headers const response = NextResponse.next(); response.headers.set('X-Frame-Options', 'DENY'); response.headers.set('X-Content-Type-Options', 'nosniff'); response.headers.set('Referrer-Policy', 'strict-origin-when-cross-origin'); response.headers.set('Permissions-Policy', 'geolocation=(), microphone=(), camera=()'); return response;}export const config = { matcher: ['/api/protected/:path*', '/admin/:path*'],};Validate and sanitize all user inputs. Use validation schemas and sanitize HTML to prevent XSS attacks.
import { NextResponse } from 'next/server';import DOMPurify from 'isomorphic-dompurify';import { z } from 'zod';// Validation schemaconst PostSchema = z.object({ title: z.string().min(1).max(200), content: z.string().min(1).max(10000), authorId: z.string().uuid(),});export async function POST(request) { try { const body = await request.json(); // Validate input const validatedData = PostSchema.parse(body); // Sanitize HTML content to prevent XSS const sanitizedContent = DOMPurify.sanitize(validatedData.content, { ALLOWED_TAGS: ['p', 'br', 'strong', 'em', 'a'], ALLOWED_ATTR: ['href'], }); // Save to database with parameterized queries const post = await db.post.create({ data: { title: validatedData.title, content: sanitizedContent, authorId: validatedData.authorId, }, }); return NextResponse.json(post, { status: 201 }); } catch (error) { if (error instanceof z.ZodError) { return NextResponse.json( { error: 'Invalid input', details: error.errors }, { status: 400 } ); } // Don't expose internal errors console.error('Post creation error:', error); return NextResponse.json( { error: 'Internal server error' }, { status: 500 } ); }}import { NextResponse } from 'next/server';import DOMPurify from 'isomorphic-dompurify';import { z } from 'zod';// Validation schemaconst PostSchema = z.object({ title: z.string().min(1).max(200), content: z.string().min(1).max(10000), authorId: z.string().uuid(),});export async function POST(request) { try { const body = await request.json(); // Validate input const validatedData = PostSchema.parse(body); // Sanitize HTML content to prevent XSS const sanitizedContent = DOMPurify.sanitize(validatedData.content, { ALLOWED_TAGS: ['p', 'br', 'strong', 'em', 'a'], ALLOWED_ATTR: ['href'], }); // Save to database with parameterized queries const post = await db.post.create({ data: { title: validatedData.title, content: sanitizedContent, authorId: validatedData.authorId, }, }); return NextResponse.json(post, { status: 201 }); } catch (error) { if (error instanceof z.ZodError) { return NextResponse.json( { error: 'Invalid input', details: error.errors }, { status: 400 } ); } // Don't expose internal errors console.error('Post creation error:', error); return NextResponse.json( { error: 'Internal server error' }, { status: 500 } ); }}Protect your APIs against abuse and DDoS attacks by implementing rate limiting. Limit the number of requests per IP or user within a time period.
import { LRUCache } from 'lru-cache';// Rate limiter with LRU cacheexport function rateLimit(options = {}) { const tokenCache = new LRUCache({ max: options.uniqueTokenPerInterval || 500, ttl: options.interval || 60000, }); return { check: (limit, token) => new Promise((resolve, reject) => { const tokenCount = tokenCache.get(token) || [0]; if (tokenCount[0] === 0) { tokenCache.set(token, [1]); } tokenCount[0] += 1; const currentUsage = tokenCount[0]; const isRateLimited = currentUsage >= limit; return isRateLimited ? reject() : resolve(); }), };}// Usage in API routeimport { NextResponse } from 'next/server';const limiter = rateLimit({ interval: 60 * 1000, // 1 minute uniqueTokenPerInterval: 500,});export async function GET(request) { const ip = request.headers.get('x-forwarded-for') || 'anonymous'; try { await limiter.check(10, ip); // 10 requests per minute // Your API logic here return NextResponse.json({ data: 'success' }); } catch { return NextResponse.json( { error: 'Rate limit exceeded' }, { status: 429 } ); }}import { LRUCache } from 'lru-cache';// Rate limiter with LRU cacheexport function rateLimit(options = {}) { const tokenCache = new LRUCache({ max: options.uniqueTokenPerInterval || 500, ttl: options.interval || 60000, }); return { check: (limit, token) => new Promise((resolve, reject) => { const tokenCount = tokenCache.get(token) || [0]; if (tokenCount[0] === 0) { tokenCache.set(token, [1]); } tokenCount[0] += 1; const currentUsage = tokenCount[0]; const isRateLimited = currentUsage >= limit; return isRateLimited ? reject() : resolve(); }), };}// Usage in API routeimport { NextResponse } from 'next/server';const limiter = rateLimit({ interval: 60 * 1000, // 1 minute uniqueTokenPerInterval: 500,});export async function GET(request) { const ip = request.headers.get('x-forwarded-for') || 'anonymous'; try { await limiter.check(10, ip); // 10 requests per minute // Your API logic here return NextResponse.json({ data: 'success' }); } catch { return NextResponse.json( { error: 'Rate limit exceeded' }, { status: 429 } ); }}Vercel provides multiple security layers: Firewall, DDoS protection, encrypted sensitive variables, and automatic HTTPS on all deployments.
# Install Vercel CLInpm i -g vercel# Set sensitive environment variables via CLIvercel env add DATABASE_URL# Select environment: Production, Preview, Development# Type: sensitive (encrypted and non-readable)# Configure Vercel Firewall (Enterprise)# Enable firewall protection against common attacks:# - SQL Injection# - Cross-Site Scripting (XSS)# - Scanner Detection# - Local File Inclusion (LFI)# View deployment logs for security monitoringvercel logs <deployment-url># Enable Attack Challenge Mode# Go to: Project Settings > Security > Attack Challenge Mode# Install Vercel CLInpm i -g vercel# Set sensitive environment variables via CLIvercel env add DATABASE_URL# Select environment: Production, Preview, Development# Type: sensitive (encrypted and non-readable)# Configure Vercel Firewall (Enterprise)# Enable firewall protection against common attacks:# - SQL Injection# - Cross-Site Scripting (XSS)# - Scanner Detection# - Local File Inclusion (LFI)# View deployment logs for security monitoringvercel logs <deployment-url># Enable Attack Challenge Mode# Go to: Project Settings > Security > Attack Challenge ModeProtect against Cross-Site Request Forgery attacks using CSRF tokens. Verify the authenticity of each request that modifies data.
import { NextResponse } from 'next/server';import { randomBytes } from 'crypto';// Generate CSRF tokenexport async function GET() { const token = randomBytes(32).toString('hex'); const response = NextResponse.json({ csrfToken: token }); // Set CSRF token in httpOnly cookie response.cookies.set('csrf-token', token, { httpOnly: true, secure: process.env.NODE_ENV === 'production', sameSite: 'strict', maxAge: 3600, // 1 hour }); return response;}import { NextResponse } from 'next/server';import { randomBytes } from 'crypto';// Generate CSRF tokenexport async function GET() { const token = randomBytes(32).toString('hex'); const response = NextResponse.json({ csrfToken: token }); // Set CSRF token in httpOnly cookie response.cookies.set('csrf-token', token, { httpOnly: true, secure: process.env.NODE_ENV === 'production', sameSite: 'strict', maxAge: 3600, // 1 hour }); return response;}Keep your dependencies updated and free from vulnerabilities. Use npm audit, Snyk, or Dependabot to detect and fix issues.
{ "name": "secure-nextjs-app", "version": "1.0.0", "scripts": { "dev": "next dev", "build": "next build", "start": "next start", "audit": "npm audit", "audit:fix": "npm audit fix", "check-updates": "npx npm-check-updates" }, "dependencies": { "next": "^16.0.0", "react": "^19.0.0", "react-dom": "^19.0.0" }, "devDependencies": { "@types/node": "^20.0.0", "@types/react": "^19.0.0", "typescript": "^5.0.0" }}{ "name": "secure-nextjs-app", "version": "1.0.0", "scripts": { "dev": "next dev", "build": "next build", "start": "next start", "audit": "npm audit", "audit:fix": "npm audit fix", "check-updates": "npx npm-check-updates" }, "dependencies": { "next": "^16.0.0", "react": "^19.0.0", "react-dom": "^19.0.0" }, "devDependencies": { "@types/node": "^20.0.0", "@types/react": "^19.0.0", "typescript": "^5.0.0" }}Configure cookies with security flags. Use httpOnly, secure, and sameSite to protect against XSS and CSRF.
import { NextResponse } from 'next/server';export async function POST(request) { const response = NextResponse.json({ success: true }); // Secure cookie configuration response.cookies.set('session', 'your-session-token', { httpOnly: true, // Prevents JavaScript access secure: true, // Only sent over HTTPS sameSite: 'strict', // CSRF protection maxAge: 60 * 60 * 24, // 1 day path: '/', }); return response;}import { NextResponse } from 'next/server';export async function POST(request) { const response = NextResponse.json({ success: true }); // Secure cookie configuration response.cookies.set('session', 'your-session-token', { httpOnly: true, // Prevents JavaScript access secure: true, // Only sent over HTTPS sameSite: 'strict', // CSRF protection maxAge: 60 * 60 * 24, // 1 day path: '/', }); return response;}Use ORMs like Prisma or parameterized queries to prevent SQL injection. Never concatenate strings directly in queries.
import { NextResponse } from 'next/server';import { PrismaClient } from '@prisma/client';const prisma = new PrismaClient();export async function GET(request) { const { searchParams } = new URL(request.url); const email = searchParams.get('email'); // ❌ BAD: Vulnerable to SQL injection // const user = await prisma.$queryRawUnsafe( // `SELECT * FROM users WHERE email = '${email}'` // ); // ✅ GOOD: Use Prisma's type-safe queries const user = await prisma.user.findUnique({ where: { email: email }, select: { id: true, email: true, name: true, // Don't select sensitive fields like password }, }); return NextResponse.json(user);}// ✅ GOOD: Using parameterized queries if neededexport async function POST(request) { const { name, email } = await request.json(); // Parameterized query prevents SQL injection const user = await prisma.$queryRaw` SELECT * FROM users WHERE name = ${name} AND email = ${email} `; return NextResponse.json(user);}import { NextResponse } from 'next/server';import { PrismaClient } from '@prisma/client';const prisma = new PrismaClient();export async function GET(request) { const { searchParams } = new URL(request.url); const email = searchParams.get('email'); // ❌ BAD: Vulnerable to SQL injection // const user = await prisma.$queryRawUnsafe( // `SELECT * FROM users WHERE email = '${email}'` // ); // ✅ GOOD: Use Prisma's type-safe queries const user = await prisma.user.findUnique({ where: { email: email }, select: { id: true, email: true, name: true, // Don't select sensitive fields like password }, }); return NextResponse.json(user);}// ✅ GOOD: Using parameterized queries if neededexport async function POST(request) { const { name, email } = await request.json(); // Parameterized query prevents SQL injection const user = await prisma.$queryRaw` SELECT * FROM users WHERE name = ${name} AND email = ${email} `; return NextResponse.json(user);}Configure security headers in next.config.js. Include HSTS, X-Frame-Options, X-Content-Type-Options, and Referrer-Policy.
/** @type {import('next').NextConfig} */const nextConfig = { reactStrictMode: true, async headers() { return [ { source: '/:path*', headers: [ { 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=()' } ], }, ]; }, images: { remotePatterns: [ { protocol: 'https', hostname: 'trusted-cdn.com', }, ], },};module.exports = nextConfig;/** @type {import('next').NextConfig} */const nextConfig = { reactStrictMode: true, async headers() { return [ { source: '/:path*', headers: [ { 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=()' } ], }, ]; }, images: { remotePatterns: [ { protocol: 'https', hostname: 'trusted-cdn.com', }, ], },};module.exports = nextConfig;Server Actions must validate authentication and authorization. Always validate inputs and verify user permissions.
'use server';import { revalidatePath } from 'next/cache';import { getSession } from '@/lib/session';import { z } from 'zod';const UpdateProfileSchema = z.object({ name: z.string().min(1).max(100), bio: z.string().max(500).optional(),});export async function updateProfile(formData) { // 1. Verify authentication const session = await getSession(); if (!session?.userId) { throw new Error('Unauthorized'); } // 2. Validate input const rawData = { name: formData.get('name'), bio: formData.get('bio'), }; const validatedData = UpdateProfileSchema.parse(rawData); // 3. Check authorization const user = await db.user.findUnique({ where: { id: session.userId }, }); if (!user) { throw new Error('User not found'); } // 4. Perform action await db.user.update({ where: { id: session.userId }, data: validatedData, }); // 5. Revalidate cache revalidatePath('/profile'); return { success: true };}'use server';import { revalidatePath } from 'next/cache';import { getSession } from '@/lib/session';import { z } from 'zod';const UpdateProfileSchema = z.object({ name: z.string().min(1).max(100), bio: z.string().max(500).optional(),});export async function updateProfile(formData) { // 1. Verify authentication const session = await getSession(); if (!session?.userId) { throw new Error('Unauthorized'); } // 2. Validate input const rawData = { name: formData.get('name'), bio: formData.get('bio'), }; const validatedData = UpdateProfileSchema.parse(rawData); // 3. Check authorization const user = await db.user.findUnique({ where: { id: session.userId }, }); if (!user) { throw new Error('User not found'); } // 4. Perform action await db.user.update({ where: { id: session.userId }, data: validatedData, }); // 5. Revalidate cache revalidatePath('/profile'); return { success: true };}Disable source maps in production to prevent exposing your original source code. Source maps reveal your application's internal structure and logic, making it easier for attackers to find vulnerabilities.
/** @type {import('next').NextConfig} */const nextConfig = { reactStrictMode: true, // Disable source maps in production productionBrowserSourceMaps: false, // Additional security configurations compiler: { // Remove console logs in production removeConsole: process.env.NODE_ENV === 'production' ? { exclude: ['error', 'warn'], } : false, }, webpack: (config, { dev, isServer }) => { if (!dev && !isServer) { // Disable source maps for client-side production builds config.devtool = false; } return config; },};module.exports = nextConfig;/** @type {import('next').NextConfig} */const nextConfig = { reactStrictMode: true, // Disable source maps in production productionBrowserSourceMaps: false, // Additional security configurations compiler: { // Remove console logs in production removeConsole: process.env.NODE_ENV === 'production' ? { exclude: ['error', 'warn'], } : false, }, webpack: (config, { dev, isServer }) => { if (!dev && !isServer) { // Disable source maps for client-side production builds config.devtool = false; } return config; },};module.exports = nextConfig;Fundamental Security Principles
• Implement CSP, CSRF, and rate limiting
• Use multiple authentication methods
• Validate on both client and server
• Monitor logs and set up alerts
• Users only access what they need
• APIs and services with limited permissions
• Environment variables only where required
• Review permissions regularly
• Validate and sanitize all inputs
• Use validation schemas (Zod, Yup)
• Implement HTML sanitization
• Prevent SQL injection and XSS
• Update dependencies regularly
• Use audit tools (npm audit, Snyk)
• Apply security patches quickly
• Monitor known vulnerabilities