Security Hardening Next.js App Beyond the Basics
Security Hardening Your Next.js App Beyond the Basics
Most Next.js security guides stop at authentication and HTTPS. But the attack surface of a modern Next.js application extends far beyond login forms: Server Actions that fetch arbitrary URLs, API routes vulnerable to prototype pollution, build pipelines susceptible to dependency confusion, and CSP headers that look secure but leak data through subtle misconfigurations.
This is a comprehensive security hardening guide for Next.js applications—the attacks most architects never fully map, and the defenses that actually work.
The Next.js Attack Surface
┌─────────────────────────────────────────────────────────────────────────────┐
│ NEXT.JS SECURITY SURFACE AREA │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ Client-Side │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ ├── XSS via dangerouslySetInnerHTML │ │
│ │ ├── XSS via URL parameters in client components │ │
│ │ ├── Sensitive data in client bundle │ │
│ │ ├── DOM clobbering attacks │ │
│ │ ├── Clickjacking (missing X-Frame-Options) │ │
│ │ └── Third-party script injection │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
│ Server Components │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ ├── Accidental data exposure in RSC payload │ │
│ │ ├── Server-side XSS (rare but possible) │ │
│ │ └── Secrets leaked through error boundaries │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
│ Server Actions │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ ├── SSRF (Server-Side Request Forgery) │ │
│ │ ├── SQL/NoSQL injection │ │
│ │ ├── Command injection │ │
│ │ ├── Path traversal │ │
│ │ ├── Mass assignment │ │
│ │ └── Missing authorization checks │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
│ API Routes │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ ├── Prototype pollution │ │
│ │ ├── ReDoS (Regular Expression DoS) │ │
│ │ ├── JSON parsing vulnerabilities │ │
│ │ ├── Missing rate limiting │ │
│ │ └── CORS misconfiguration │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
│ Build & Deploy │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ ├── Dependency confusion attacks │ │
│ │ ├── Supply chain compromise │ │
│ │ ├── Secrets in build output │ │
│ │ ├── Source map exposure │ │
│ │ └── Environment variable leakage │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
│ Infrastructure │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ ├── Missing security headers │ │
│ │ ├── Cache poisoning │ │
│ │ ├── Host header injection │ │
│ │ └── Open redirects │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
Content Security Policy (CSP): Beyond the Boilerplate
CSP is your primary defense against XSS. But most implementations are either too permissive (useless) or too restrictive (breaks the app).
The CSP Mental Model
┌─────────────────────────────────────────────────────────────────────────────┐
│ CSP DIRECTIVE MAP │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ What can load what: │
│ │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────────────────────┐ │
│ │ default-src │────►│ Fallback │ │ If specific directive not │ │
│ │ │ │ for all │ │ defined, this is used │ │
│ └─────────────┘ └─────────────┘ └─────────────────────────────┘ │
│ │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────────────────────┐ │
│ │ script-src │────►│ JavaScript │ │ Most critical for XSS │ │
│ │ │ │ execution │ │ prevention │ │
│ └─────────────┘ └─────────────┘ └─────────────────────────────┘ │
│ │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────────────────────┐ │
│ │ style-src │────►│ CSS loading │ │ Prevent CSS injection │ │
│ │ │ │ │ │ (data exfiltration) │ │
│ └─────────────┘ └─────────────┘ └─────────────────────────────┘ │
│ │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────────────────────┐ │
│ │ connect-src │────►│ fetch/XHR │ │ API calls, WebSocket │ │
│ │ │ │ WebSocket │ │ │ │
│ └─────────────┘ └─────────────┘ └─────────────────────────────┘ │
│ │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────────────────────┐ │
│ │ img-src │────►│ Images │ │ Including data: URIs │ │
│ └─────────────┘ └─────────────┘ └─────────────────────────────┘ │
│ │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────────────────────┐ │
│ │ frame-src │────►│ iframes │ │ Embedded content │ │
│ └─────────────┘ └─────────────┘ └─────────────────────────────┘ │
│ │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────────────────────┐ │
│ │ form-action │────►│ Form POST │ │ Where forms can submit │ │
│ │ │ │ targets │ │ │ │
│ └─────────────┘ └─────────────┘ └─────────────────────────────┘ │
│ │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────────────────────┐ │
│ │ base-uri │────►│ <base> tag │ │ Prevent base tag injection │ │
│ └─────────────┘ └─────────────┘ └─────────────────────────────┘ │
│ │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────────────────────┐ │
│ │ report-uri │────►│ Violation │ │ Know when CSP blocks │ │
│ │ report-to │ │ reporting │ │ something │ │
│ └─────────────┘ └─────────────┘ └─────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
Production CSP Implementation
// middleware.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
// Generate nonce for each request
function generateNonce(): string {
const array = new Uint8Array(16);
crypto.getRandomValues(array);
return Buffer.from(array).toString('base64');
}
export function middleware(request: NextRequest) {
const nonce = generateNonce();
const response = NextResponse.next();
// Store nonce for use in pages
response.headers.set('x-nonce', nonce);
// Build CSP header
const csp = buildCSP(nonce, request);
response.headers.set('Content-Security-Policy', csp);
// Other security headers
response.headers.set('X-Content-Type-Options', 'nosniff');
response.headers.set('X-Frame-Options', 'DENY');
response.headers.set('X-XSS-Protection', '1; mode=block');
response.headers.set('Referrer-Policy', 'strict-origin-when-cross-origin');
response.headers.set(
'Permissions-Policy',
'camera=(), microphone=(), geolocation=()'
);
// HSTS (only in production)
if (process.env.NODE_ENV === 'production') {
response.headers.set(
'Strict-Transport-Security',
'max-age=31536000; includeSubDomains; preload'
);
}
return response;
}
function buildCSP(nonce: string, request: NextRequest): string {
const isDev = process.env.NODE_ENV === 'development';
// Base directives
const directives: Record<string, string[]> = {
'default-src': ["'self'"],
'script-src': [
"'self'",
`'nonce-${nonce}'`,
"'strict-dynamic'", // Allow scripts loaded by nonced scripts
// Fallbacks for browsers without strict-dynamic support
...(isDev ? ["'unsafe-eval'"] : []), // Required for React Fast Refresh
],
'style-src': [
"'self'",
`'nonce-${nonce}'`,
// Or use 'unsafe-inline' if you can't nonce all styles
],
'img-src': [
"'self'",
'data:',
'blob:',
'https://your-cdn.com',
'https://*.googleusercontent.com', // If using Google auth
],
'font-src': ["'self'", 'https://fonts.gstatic.com'],
'connect-src': [
"'self'",
'https://your-api.com',
'https://vitals.vercel-insights.com', // Vercel Analytics
...(isDev ? ['ws://localhost:3000'] : []), // HMR WebSocket
],
'frame-src': [
"'self'",
'https://js.stripe.com', // Stripe
'https://www.youtube.com', // Embeds
],
'frame-ancestors': ["'none'"], // Prevent clickjacking
'form-action': ["'self'"],
'base-uri': ["'self'"],
'object-src': ["'none'"],
'upgrade-insecure-requests': [],
};
// Add report-uri in production
if (!isDev) {
directives['report-uri'] = ['/api/csp-report'];
directives['report-to'] = ['csp-endpoint'];
}
return Object.entries(directives)
.map(([key, values]) =>
values.length > 0 ? `${key} ${values.join(' ')}` : key
)
.join('; ');
}
export const config = {
matcher: [
/*
* Match all request paths except:
* - _next/static (static files)
* - _next/image (image optimization files)
* - favicon.ico (favicon file)
* - public files
*/
'/((?!_next/static|_next/image|favicon.ico|public/).*)',
],
};
Nonce-Based Script Injection
// app/layout.tsx
import { headers } from 'next/headers';
import Script from 'next/script';
export default async function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
const headersList = await headers();
const nonce = headersList.get('x-nonce') ?? '';
return (
<html lang="en">
<head>
{/* Inline scripts need the nonce */}
<Script
id="theme-script"
nonce={nonce}
dangerouslySetInnerHTML={{
__html: `
(function() {
const theme = localStorage.getItem('theme') || 'light';
document.documentElement.setAttribute('data-theme', theme);
})();
`,
}}
/>
{/* External scripts with nonce */}
<Script
src="https://js.stripe.com/v3/"
nonce={nonce}
strategy="lazyOnload"
/>
</head>
<body>
<NonceProvider nonce={nonce}>{children}</NonceProvider>
</body>
</html>
);
}
// Context for nonce
// components/NonceProvider.tsx
'use client';
import { createContext, useContext } from 'react';
const NonceContext = createContext<string>('');
export function NonceProvider({
nonce,
children,
}: {
nonce: string;
children: React.ReactNode;
}) {
return (
<NonceContext.Provider value={nonce}>{children}</NonceContext.Provider>
);
}
export function useNonce(): string {
return useContext(NonceContext);
}
// Using nonce in components
// components/DynamicScript.tsx
'use client';
import { useNonce } from './NonceProvider';
export function DynamicScript({ code }: { code: string }) {
const nonce = useNonce();
return (
<script
nonce={nonce}
dangerouslySetInnerHTML={{ __html: code }}
/>
);
}
CSP Reporting
// app/api/csp-report/route.ts
import { NextRequest } from 'next/server';
interface CSPViolationReport {
'csp-report': {
'document-uri': string;
'violated-directive': string;
'effective-directive': string;
'original-policy': string;
'blocked-uri': string;
'status-code': number;
'source-file'?: string;
'line-number'?: number;
'column-number'?: number;
};
}
export async function POST(request: NextRequest) {
try {
const report: CSPViolationReport = await request.json();
const violation = report['csp-report'];
// Log for monitoring
console.error('CSP Violation:', {
documentUri: violation['document-uri'],
violatedDirective: violation['violated-directive'],
blockedUri: violation['blocked-uri'],
sourceFile: violation['source-file'],
lineNumber: violation['line-number'],
});
// Send to monitoring service
await sendToMonitoring({
type: 'csp_violation',
...violation,
timestamp: new Date().toISOString(),
userAgent: request.headers.get('user-agent'),
});
return new Response(null, { status: 204 });
} catch (error) {
console.error('Failed to process CSP report:', error);
return new Response(null, { status: 400 });
}
}
// Alert on suspicious patterns
async function sendToMonitoring(data: Record<string, unknown>) {
const suspiciousPatterns = [
'eval', // Attempted eval()
'inline', // Inline script blocked
'data:', // Data URI blocked
];
const isSuspicious = suspiciousPatterns.some(
(pattern) =>
String(data['blocked-uri']).includes(pattern) ||
String(data['violated-directive']).includes(pattern)
);
if (isSuspicious) {
// High priority alert
await alertSecurityTeam(data);
}
// Always log to analytics
await fetch(process.env.MONITORING_ENDPOINT!, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
}
SSRF Prevention in Server Actions
Server-Side Request Forgery (SSRF) is one of the most dangerous vulnerabilities in Server Actions. An attacker can make your server fetch internal resources, cloud metadata endpoints, or attack internal services.
The SSRF Attack Vector
┌─────────────────────────────────────────────────────────────────────────────┐
│ SSRF ATTACK FLOW │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ Attacker │
│ ┌───────────────────────────────────────────────────────────────────┐ │
│ │ │ │
│ │ POST /api/fetch-image │ │
│ │ Body: { "url": "http://169.254.169.254/latest/meta-data/" } │ │
│ │ │ │
│ │ Or: { "url": "http://localhost:6379/CONFIG" } │ │
│ │ Or: { "url": "file:///etc/passwd" } │ │
│ │ Or: { "url": "http://internal-admin.company.local/" } │ │
│ │ │ │
│ └───────────────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ Next.js Server │
│ ┌───────────────────────────────────────────────────────────────────┐ │
│ │ │ │
│ │ // Vulnerable Server Action │ │
│ │ async function fetchImage(url: string) { │ │
│ │ 'use server'; │ │
│ │ const response = await fetch(url); // SSRF! │ │
│ │ return response.blob(); │ │
│ │ } │ │
│ │ │ │
│ └───────────────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ Internal Network │
│ ┌───────────────────────────────────────────────────────────────────┐ │
│ │ │ │
│ │ Server fetches: │ │
│ │ - AWS metadata (credentials leak) │ │
│ │ - Internal Redis (data access) │ │
│ │ - Local files (sensitive config) │ │
│ │ - Admin panels (privilege escalation) │ │
│ │ │ │
│ └───────────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
SSRF-Safe URL Validation
// lib/security/ssrf.ts
import { URL } from 'url';
import dns from 'dns/promises';
// Known dangerous IP ranges
const BLOCKED_IP_RANGES = [
// Loopback
/^127\./,
/^0\./,
/^::1$/,
/^localhost$/i,
// Private networks (RFC 1918)
/^10\./,
/^172\.(1[6-9]|2[0-9]|3[0-1])\./,
/^192\.168\./,
// Link-local
/^169\.254\./,
/^fe80:/i,
// Cloud metadata endpoints
/^169\.254\.169\.254$/, // AWS, GCP, Azure
/^metadata\.google\.internal$/i,
/^100\.100\.100\.200$/, // Alibaba
// IPv6 private
/^fc00:/i,
/^fd00:/i,
];
// Allowed protocols
const ALLOWED_PROTOCOLS = ['https:', 'http:'];
// Optional: Allowlist of domains
const ALLOWED_DOMAINS = [
/^.*\.trusted-cdn\.com$/,
/^api\.external-service\.com$/,
// Add your trusted domains
];
interface SSRFValidationResult {
safe: boolean;
reason?: string;
resolvedUrl?: URL;
}
export async function validateUrlForSSRF(
urlString: string,
options: {
allowHttp?: boolean;
useAllowlist?: boolean;
timeout?: number;
} = {}
): Promise<SSRFValidationResult> {
const { allowHttp = false, useAllowlist = false, timeout = 5000 } = options;
try {
// Parse URL
const url = new URL(urlString);
// 1. Protocol check
const allowedProtocols = allowHttp ? ALLOWED_PROTOCOLS : ['https:'];
if (!allowedProtocols.includes(url.protocol)) {
return {
safe: false,
reason: `Protocol ${url.protocol} not allowed`,
};
}
// 2. Hostname checks
const hostname = url.hostname.toLowerCase();
// Block IP addresses directly in URL
if (isIPAddress(hostname)) {
if (isBlockedIP(hostname)) {
return {
safe: false,
reason: 'Direct IP address to internal network not allowed',
};
}
}
// 3. Domain allowlist (if enabled)
if (useAllowlist) {
const isAllowed = ALLOWED_DOMAINS.some((pattern) =>
pattern.test(hostname)
);
if (!isAllowed) {
return {
safe: false,
reason: 'Domain not in allowlist',
};
}
}
// 4. DNS resolution check (prevent DNS rebinding)
const resolvedIPs = await resolveDNS(hostname, timeout);
for (const ip of resolvedIPs) {
if (isBlockedIP(ip)) {
return {
safe: false,
reason: `DNS resolves to blocked IP: ${ip}`,
};
}
}
// 5. Block special hostnames
if (
hostname.includes('metadata') ||
hostname.includes('internal') ||
hostname.includes('localhost') ||
hostname.includes('local')
) {
return {
safe: false,
reason: 'Hostname contains blocked keyword',
};
}
// 6. Port check (block common internal service ports)
const blockedPorts = [
22, 23, 25, 110, 143, 445, 3306, 5432, 6379, 27017, 11211,
];
const port = url.port ? parseInt(url.port) : url.protocol === 'https:' ? 443 : 80;
if (blockedPorts.includes(port)) {
return {
safe: false,
reason: `Port ${port} is blocked`,
};
}
return {
safe: true,
resolvedUrl: url,
};
} catch (error) {
return {
safe: false,
reason: `URL parsing failed: ${(error as Error).message}`,
};
}
}
function isIPAddress(str: string): boolean {
// IPv4
if (/^\d{1,3}(\.\d{1,3}){3}$/.test(str)) return true;
// IPv6
if (str.includes(':')) return true;
return false;
}
function isBlockedIP(ip: string): boolean {
return BLOCKED_IP_RANGES.some((pattern) => pattern.test(ip));
}
async function resolveDNS(hostname: string, timeout: number): Promise<string[]> {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeout);
try {
const addresses = await Promise.race([
dns.resolve4(hostname).catch(() => []),
dns.resolve6(hostname).catch(() => []),
]);
return addresses;
} finally {
clearTimeout(timeoutId);
}
}
// Safe fetch wrapper
export async function safeFetch(
urlString: string,
init?: RequestInit
): Promise<Response> {
const validation = await validateUrlForSSRF(urlString, {
allowHttp: false,
useAllowlist: true,
});
if (!validation.safe) {
throw new SSRFError(validation.reason || 'URL validation failed');
}
// Additional fetch safeguards
const safeInit: RequestInit = {
...init,
redirect: 'error', // Don't follow redirects (could redirect to internal)
signal: AbortSignal.timeout(30000), // Timeout
};
return fetch(urlString, safeInit);
}
export class SSRFError extends Error {
constructor(message: string) {
super(message);
this.name = 'SSRFError';
}
}
Server Action Implementation
// app/actions/fetch-external.ts
'use server';
import { safeFetch, SSRFError } from '@/lib/security/ssrf';
import { z } from 'zod';
const FetchImageSchema = z.object({
url: z.string().url().max(2048),
});
export async function fetchExternalImage(formData: FormData) {
// 1. Input validation
const parsed = FetchImageSchema.safeParse({
url: formData.get('url'),
});
if (!parsed.success) {
return { error: 'Invalid URL format' };
}
try {
// 2. SSRF-safe fetch
const response = await safeFetch(parsed.data.url);
// 3. Content-Type validation
const contentType = response.headers.get('content-type');
if (!contentType?.startsWith('image/')) {
return { error: 'URL does not point to an image' };
}
// 4. Size limit
const contentLength = response.headers.get('content-length');
if (contentLength && parseInt(contentLength) > 10 * 1024 * 1024) {
return { error: 'Image too large (max 10MB)' };
}
// 5. Process image
const buffer = await response.arrayBuffer();
// ... process and store image
return { success: true };
} catch (error) {
if (error instanceof SSRFError) {
// Log security event
console.error('SSRF attempt blocked:', {
url: parsed.data.url,
reason: error.message,
});
return { error: 'URL not allowed' };
}
return { error: 'Failed to fetch image' };
}
}
Prototype Pollution in API Routes
Prototype pollution allows attackers to modify Object.prototype, affecting all objects in your application. This can lead to authentication bypass, RCE, or DoS.
The Prototype Pollution Attack
┌─────────────────────────────────────────────────────────────────────────────┐
│ PROTOTYPE POLLUTION ATTACK │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ Vulnerable Pattern: │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ │ │
│ │ // Vulnerable merge function │ │
│ │ function merge(target, source) { │ │
│ │ for (const key in source) { │ │
│ │ if (typeof source[key] === 'object') { │ │
│ │ target[key] = merge(target[key] || {}, source[key]); │ │
│ │ } else { │ │
│ │ target[key] = source[key]; │ │
│ │ } │ │
│ │ } │ │
│ │ return target; │ │
│ │ } │ │
│ │ │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
│ Attack Payload: │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ │ │
│ │ POST /api/user/settings │ │
│ │ { │ │
│ │ "__proto__": { │ │
│ │ "isAdmin": true │ │
│ │ } │ │
│ │ } │ │
│ │ │ │
│ │ // Or using constructor │ │
│ │ { │ │
│ │ "constructor": { │ │
│ │ "prototype": { │ │
│ │ "isAdmin": true │ │
│ │ } │ │
│ │ } │ │
│ │ } │ │
│ │ │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
│ Result: │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ │ │
│ │ // After pollution │ │
│ │ const user = {}; │ │
│ │ console.log(user.isAdmin); // true (inherited from prototype!) │ │
│ │ │ │
│ │ // Bypass authorization │ │
│ │ if (user.isAdmin) { │ │
│ │ // Attacker gains admin access │ │
│ │ } │ │
│ │ │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
Prototype Pollution Prevention
// lib/security/sanitize.ts
// Dangerous keys that can pollute prototype
const PROTOTYPE_POLLUTION_KEYS = new Set([
'__proto__',
'constructor',
'prototype',
]);
/**
* Deep clone an object while preventing prototype pollution
*/
export function safeDeepClone<T>(obj: T): T {
return JSON.parse(JSON.stringify(obj), (key, value) => {
// Skip dangerous keys entirely
if (PROTOTYPE_POLLUTION_KEYS.has(key)) {
return undefined;
}
return value;
});
}
/**
* Safe object merge that prevents prototype pollution
*/
export function safeMerge<T extends object>(
target: T,
...sources: Partial<T>[]
): T {
for (const source of sources) {
if (!source || typeof source !== 'object') continue;
for (const key of Object.keys(source)) {
// Skip prototype pollution vectors
if (PROTOTYPE_POLLUTION_KEYS.has(key)) {
continue;
}
const sourceValue = (source as Record<string, unknown>)[key];
const targetValue = (target as Record<string, unknown>)[key];
if (
sourceValue &&
typeof sourceValue === 'object' &&
!Array.isArray(sourceValue)
) {
// Recursively merge objects
(target as Record<string, unknown>)[key] = safeMerge(
(targetValue as object) || {},
sourceValue as object
);
} else {
// Direct assignment for primitives and arrays
(target as Record<string, unknown>)[key] = sourceValue;
}
}
}
return target;
}
/**
* Validate and sanitize incoming JSON
*/
export function sanitizeJson<T>(json: unknown): T {
// Convert to string and back to strip any prototype references
const str = JSON.stringify(json, (key, value) => {
if (PROTOTYPE_POLLUTION_KEYS.has(key)) {
// Log attempt
console.warn('Prototype pollution attempt detected:', { key });
return undefined;
}
return value;
});
return JSON.parse(str);
}
/**
* Create an object with null prototype (immune to pollution)
*/
export function createSafeObject<T extends object>(initial?: T): T {
const obj = Object.create(null);
if (initial) {
Object.assign(obj, safeDeepClone(initial));
}
return obj;
}
Safe API Route
// app/api/user/settings/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { z } from 'zod';
import { sanitizeJson } from '@/lib/security/sanitize';
import { getSession } from '@/lib/auth';
// Strict schema - no additional properties
const SettingsSchema = z
.object({
theme: z.enum(['light', 'dark', 'system']),
notifications: z.boolean(),
language: z.string().max(10),
})
.strict(); // Reject additional properties
export async function POST(request: NextRequest) {
// 1. Auth check
const session = await getSession();
if (!session) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
try {
// 2. Parse and sanitize JSON
const rawBody = await request.json();
const sanitizedBody = sanitizeJson(rawBody);
// 3. Validate with strict schema
const parsed = SettingsSchema.safeParse(sanitizedBody);
if (!parsed.success) {
return NextResponse.json(
{ error: 'Invalid settings', details: parsed.error.flatten() },
{ status: 400 }
);
}
// 4. Update with validated data only
await updateUserSettings(session.userId, parsed.data);
return NextResponse.json({ success: true });
} catch (error) {
console.error('Settings update error:', error);
return NextResponse.json(
{ error: 'Failed to update settings' },
{ status: 500 }
);
}
}
// Safe update function
async function updateUserSettings(
userId: string,
settings: z.infer<typeof SettingsSchema>
) {
// Only update allowed fields explicitly
await db.user.update({
where: { id: userId },
data: {
settings: {
theme: settings.theme,
notifications: settings.notifications,
language: settings.language,
},
},
});
}
Object.freeze for Critical Objects
// lib/config/permissions.ts
// Freeze permission definitions
export const Permissions = Object.freeze({
READ: 'read',
WRITE: 'write',
DELETE: 'delete',
ADMIN: 'admin',
} as const);
// Freeze role definitions
export const Roles = Object.freeze({
user: Object.freeze([Permissions.READ]),
editor: Object.freeze([Permissions.READ, Permissions.WRITE]),
admin: Object.freeze([
Permissions.READ,
Permissions.WRITE,
Permissions.DELETE,
Permissions.ADMIN,
]),
} as const);
// Deep freeze for nested objects
function deepFreeze<T extends object>(obj: T): Readonly<T> {
Object.keys(obj).forEach((key) => {
const value = (obj as Record<string, unknown>)[key];
if (value && typeof value === 'object') {
deepFreeze(value as object);
}
});
return Object.freeze(obj);
}
// Freeze config at startup
export const SecurityConfig = deepFreeze({
session: {
maxAge: 7 * 24 * 60 * 60,
secure: true,
httpOnly: true,
},
rateLimit: {
window: 60,
max: 100,
},
});
Dependency Confusion Attacks
Dependency confusion exploits package manager resolution to install malicious packages from public registries instead of intended internal packages.
The Attack Vector
┌─────────────────────────────────────────────────────────────────────────────┐
│ DEPENDENCY CONFUSION ATTACK │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ Your package.json: │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ { │ │
│ │ "dependencies": { │ │
│ │ "@company/internal-utils": "^1.0.0" // Internal package │ │
│ │ } │ │
│ │ } │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
│ Attack scenario: │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ │ │
│ │ 1. Attacker discovers internal package name │ │
│ │ (from error messages, job postings, GitHub snippets) │ │
│ │ │ │
│ │ 2. Attacker publishes to public npm: │ │
│ │ @company/internal-utils@9999.0.0 │ │
│ │ (with malicious postinstall script) │ │
│ │ │ │
│ │ 3. npm install resolves: │ │
│ │ - Internal registry: v1.0.0 │ │
│ │ - Public npm: v9999.0.0 (higher!) │ │
│ │ │ │
│ │ 4. npm installs v9999.0.0 from public (attacker's package!) │ │
│ │ │ │
│ │ 5. postinstall runs: exfiltrates secrets, installs backdoor │ │
│ │ │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
Prevention Strategies
# .npmrc - Registry configuration
# Option 1: Scoped packages to internal registry
@company:registry=https://npm.internal.company.com/
//npm.internal.company.com/:_authToken=${NPM_TOKEN}
# Option 2: Use package-lock.json with integrity
package-lock=true
# Option 3: Disable scripts for safety
ignore-scripts=true
// scripts/audit-dependencies.ts
import { execSync } from 'child_process';
import * as fs from 'fs';
interface PackageLock {
packages: Record<
string,
{
version: string;
resolved: string;
integrity: string;
}
>;
}
// Known internal scopes
const INTERNAL_SCOPES = ['@company', '@internal'];
// Expected internal registry
const INTERNAL_REGISTRY = 'https://npm.internal.company.com/';
function auditDependencies() {
const lockFile = JSON.parse(
fs.readFileSync('./package-lock.json', 'utf-8')
) as PackageLock;
const violations: string[] = [];
for (const [name, pkg] of Object.entries(lockFile.packages)) {
// Skip root package
if (name === '') continue;
const packageName = name.replace('node_modules/', '');
// Check if internal scope
const isInternal = INTERNAL_SCOPES.some((scope) =>
packageName.startsWith(scope)
);
if (isInternal) {
// Verify it's from internal registry
if (!pkg.resolved?.startsWith(INTERNAL_REGISTRY)) {
violations.push(
`${packageName}@${pkg.version} resolved from wrong registry: ${pkg.resolved}`
);
}
// Check for suspiciously high version numbers
const majorVersion = parseInt(pkg.version.split('.')[0], 10);
if (majorVersion > 100) {
violations.push(
`${packageName}@${pkg.version} has suspiciously high version number`
);
}
}
// Check for missing integrity hashes
if (!pkg.integrity) {
violations.push(`${packageName}@${pkg.version} missing integrity hash`);
}
}
if (violations.length > 0) {
console.error('Dependency audit failed!');
violations.forEach((v) => console.error(` - ${v}`));
process.exit(1);
}
console.log('Dependency audit passed');
}
auditDependencies();
# .github/workflows/security.yml
name: Security Checks
on:
pull_request:
paths:
- 'package.json'
- 'package-lock.json'
jobs:
dependency-audit:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Verify package-lock.json integrity
run: |
# Ensure package-lock is not modified
npm ci --ignore-scripts
# Check for unexpected registry sources
node scripts/audit-dependencies.ts
- name: Run npm audit
run: npm audit --audit-level=high
- name: Check for known vulnerabilities
uses: snyk/actions/node@master
env:
SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }}
- name: Verify no internal packages on public npm
run: |
# Check if any internal scope packages exist on public npm
for scope in @company @internal; do
if npm search "$scope" --json 2>/dev/null | jq -e '.length > 0' > /dev/null; then
echo "WARNING: Found packages with internal scope on public npm"
npm search "$scope" --json
exit 1
fi
done
Supply Chain Security
// next.config.js
const { execSync } = require('child_process');
// Verify dependencies at build time
function verifyDependencies() {
const critical = ['react', 'next', '@prisma/client'];
for (const pkg of critical) {
const info = JSON.parse(
execSync(`npm view ${pkg} --json`, { encoding: 'utf-8' })
);
// Verify publisher
if (pkg === 'react' && info.maintainers?.[0]?.name !== 'react-bot') {
throw new Error(`Unexpected maintainer for ${pkg}`);
}
// Verify package hasn't been unpublished and republished
// (Check creation date matches expected)
}
}
if (process.env.NODE_ENV === 'production') {
verifyDependencies();
}
/** @type {import('next').NextConfig} */
const nextConfig = {
// ...
};
module.exports = nextConfig;
Additional Security Measures
Server Action Security
// lib/security/server-action.ts
import { headers } from 'next/headers';
import { getSession } from '@/lib/auth';
import { rateLimit } from '@/lib/rate-limit';
interface ActionContext {
userId: string;
ip: string;
userAgent: string;
}
type ServerAction<TInput, TOutput> = (
input: TInput,
context: ActionContext
) => Promise<TOutput>;
/**
* Wrapper for secure server actions
*/
export function secureAction<TInput, TOutput>(
action: ServerAction<TInput, TOutput>,
options: {
requireAuth?: boolean;
rateLimit?: { window: number; max: number };
audit?: boolean;
} = {}
) {
const { requireAuth = true, rateLimit: rateLimitConfig, audit = false } = options;
return async (input: TInput): Promise<TOutput> => {
const headersList = await headers();
// Get client info
const ip =
headersList.get('x-forwarded-for')?.split(',')[0] ||
headersList.get('x-real-ip') ||
'unknown';
const userAgent = headersList.get('user-agent') || 'unknown';
// Rate limiting
if (rateLimitConfig) {
const { success } = await rateLimit.check(
ip,
rateLimitConfig.window,
rateLimitConfig.max
);
if (!success) {
throw new Error('Rate limit exceeded');
}
}
// Authentication
let userId = 'anonymous';
if (requireAuth) {
const session = await getSession();
if (!session) {
throw new Error('Unauthorized');
}
userId = session.userId;
}
// Audit logging
if (audit) {
await logAuditEvent({
action: action.name,
userId,
ip,
input: sanitizeForLog(input),
timestamp: new Date(),
});
}
// Execute action
try {
return await action(input, { userId, ip, userAgent });
} catch (error) {
// Log error but don't expose details
console.error('Server action error:', {
action: action.name,
userId,
error: (error as Error).message,
});
throw new Error('Action failed');
}
};
}
function sanitizeForLog(input: unknown): unknown {
const str = JSON.stringify(input);
// Redact sensitive fields
return JSON.parse(
str.replace(
/"(password|token|secret|key|auth)"\s*:\s*"[^"]*"/gi,
'"$1":"[REDACTED]"'
)
);
}
Authorization Patterns
// lib/security/authorization.ts
import { getSession } from '@/lib/auth';
import { db } from '@/lib/db';
// Resource-based authorization
export async function authorize<T>(
resource: string,
action: 'read' | 'write' | 'delete',
resourceId: string
): Promise<T | null> {
const session = await getSession();
if (!session) return null;
// Check permission
const hasPermission = await checkPermission(
session.userId,
resource,
action,
resourceId
);
if (!hasPermission) {
await logUnauthorizedAccess({
userId: session.userId,
resource,
action,
resourceId,
});
return null;
}
// Return resource
return db[resource].findUnique({ where: { id: resourceId } }) as Promise<T>;
}
// Use in Server Action
export async function updateDocument(id: string, content: string) {
'use server';
const document = await authorize<Document>('document', 'write', id);
if (!document) {
throw new Error('Document not found or access denied');
}
// Safe to update
return db.document.update({
where: { id },
data: { content },
});
}
Input Validation Patterns
// lib/validation/schemas.ts
import { z } from 'zod';
// Reusable validators
export const SafeString = z
.string()
.trim()
.min(1)
.max(10000)
.refine((val) => !val.includes('\x00'), 'Null bytes not allowed');
export const SafeHtml = z
.string()
.transform((val) => sanitizeHtml(val, allowedTags));
export const SafeUrl = z
.string()
.url()
.refine((url) => {
try {
const parsed = new URL(url);
return ['https:', 'http:'].includes(parsed.protocol);
} catch {
return false;
}
}, 'Invalid URL protocol');
export const SafeEmail = z
.string()
.email()
.toLowerCase()
.max(254);
export const SafeId = z
.string()
.regex(/^[a-zA-Z0-9_-]+$/, 'Invalid ID format')
.min(1)
.max(128);
// Strict pagination to prevent DoS
export const Pagination = z.object({
page: z.coerce.number().int().min(1).max(1000).default(1),
limit: z.coerce.number().int().min(1).max(100).default(20),
});
// File upload validation
export const FileUpload = z.object({
name: z.string().max(255).regex(/^[^<>:"/\\|?*\x00-\x1f]+$/),
type: z.enum(['image/jpeg', 'image/png', 'image/webp', 'application/pdf']),
size: z.number().max(10 * 1024 * 1024), // 10MB
});
Security Headers Reference
// lib/security/headers.ts
export const securityHeaders = {
// Prevent MIME type sniffing
'X-Content-Type-Options': 'nosniff',
// Prevent clickjacking
'X-Frame-Options': 'DENY',
// XSS filter (legacy, but still useful)
'X-XSS-Protection': '1; mode=block',
// Control referrer information
'Referrer-Policy': 'strict-origin-when-cross-origin',
// Disable browser features
'Permissions-Policy': [
'camera=()',
'microphone=()',
'geolocation=()',
'interest-cohort=()', // Disable FLoC
].join(', '),
// HSTS
'Strict-Transport-Security': 'max-age=31536000; includeSubDomains; preload',
// Prevent XS-Leaks
'Cross-Origin-Opener-Policy': 'same-origin',
'Cross-Origin-Embedder-Policy': 'require-corp',
'Cross-Origin-Resource-Policy': 'same-origin',
};
// next.config.js
/** @type {import('next').NextConfig} */
const nextConfig = {
async headers() {
return [
{
source: '/:path*',
headers: Object.entries(securityHeaders).map(([key, value]) => ({
key,
value,
})),
},
];
},
// Disable x-powered-by
poweredByHeader: false,
// Enable strict mode
reactStrictMode: true,
// Restrict image domains
images: {
domains: ['trusted-cdn.com'],
dangerouslyAllowSVG: false,
},
};
module.exports = nextConfig;
Production Security Checklist
┌─────────────────────────────────────────────────────────────────────────────┐
│ NEXT.JS SECURITY HARDENING CHECKLIST │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ Headers & CSP │
│ □ CSP implemented with nonces (not 'unsafe-inline') │
│ □ strict-dynamic for trusted script loading │
│ □ CSP violation reporting enabled │
│ □ X-Frame-Options: DENY │
│ □ HSTS with includeSubDomains │
│ □ Permissions-Policy restricts dangerous APIs │
│ □ COOP/COEP/CORP headers set │
│ │
│ Server Actions │
│ □ All actions validate input with Zod │
│ □ SSRF prevention for any URL inputs │
│ □ Authorization checks on every action │
│ □ Rate limiting implemented │
│ □ Audit logging for sensitive actions │
│ │
│ API Routes │
│ □ Prototype pollution prevention │
│ □ JSON schema validation │
│ □ No sensitive data in error messages │
│ □ CORS properly configured │
│ □ Rate limiting per route │
│ │
│ Dependencies │
│ □ npm audit passes (no high/critical) │
│ □ Dependency confusion prevention (.npmrc) │
│ □ package-lock.json committed and verified │
│ □ ignore-scripts in production build │
│ □ Regular dependency updates │
│ │
│ Build & Deploy │
│ □ Source maps disabled in production │
│ □ No secrets in client bundle │
│ □ Environment variables validated │
│ □ Build-time security checks │
│ □ Container security scanning │
│ │
│ Authentication │
│ □ Secure session management │
│ □ CSRF protection │
│ □ Password hashing (argon2/bcrypt) │
│ □ Brute force protection │
│ □ Secure password reset flow │
│ │
│ Data Protection │
│ □ Sensitive data encrypted at rest │
│ □ PII handling compliant │
│ □ Database queries parameterized │
│ □ No sensitive data in logs │
│ □ Secure file upload handling │
│ │
│ Monitoring │
│ □ Security event logging │
│ □ CSP violation alerts │
│ □ Anomaly detection │
│ □ Dependency vulnerability alerts │
│ □ Incident response plan │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
The Bottom Line
Next.js security extends far beyond what most tutorials cover:
-
CSP with nonces is non-negotiable. 'unsafe-inline' defeats the purpose. Implement proper nonce propagation and strict-dynamic.
-
SSRF in Server Actions is a real threat. Validate and sanitize every URL before fetching. Resolve DNS and check against blocked ranges.
-
Prototype pollution can bypass your entire authorization system. Use strict Zod schemas, sanitize JSON, and freeze critical objects.
-
Dependency confusion targets your build pipeline. Lock your registry configuration, audit package-lock.json, and verify internal package sources.
-
Defense in depth means multiple layers. Input validation, output encoding, CSP, authorization checks, rate limiting—each catches what others miss.
Security isn't a feature you add. It's a discipline you maintain. The attacks in this guide represent the current threat landscape, but it evolves constantly. Stay updated, run regular audits, and assume every input is malicious until proven otherwise.
What did you think?