Next.js

Next.js Security

32 min Lesson 34 of 40

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:

  1. CSRF protection with tokens
  2. Input validation using Zod
  3. XSS prevention with sanitization
  4. Rate limiting (5 requests per 10 minutes per IP)
  5. CSP headers with nonce for inline scripts
  6. Proper error handling without exposing sensitive info
  7. 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.