Next.js Lesson 28 – Security in Next.js | Dataplexa
Lesson 28

Security in Next.js

Secure your NewsWave application against common vulnerabilities and implement security best practices throughout your Next.js stack.

Security isn't just about passwords and locks. Think of it like building a house — you need secure foundations, proper doors, good locks, and smart systems that keep the bad actors out while letting legitimate visitors in comfortably. Next.js applications face unique security challenges because they run code in multiple environments. Your components render on the server, your API routes handle sensitive data, and your client-side code runs in browsers you don't control. Each environment needs its own security strategy. Unlike traditional client-only React apps, Next.js gives you server-side powers. With great power comes great responsibility — server-side code can access databases, file systems, and environment variables that should never leak to the browser. One misplaced console.log in the wrong place could expose API keys to every visitor.

Environment Variables Security

Environment variables hold your secrets — database URLs, API keys, JWT secrets. Next.js has a built-in system that prevents most accidents, but you need to understand how it works. Next.js treats environment variables differently based on their prefix. Variables starting with NEXT_PUBLIC_ become available in the browser. Everything else stays server-only. This simple naming convention prevents most environment variable leaks. But accidents happen. Developers sometimes expose server-only variables by destructuring process.env in client components or logging sensitive data during development. The NewsWave team needs bulletproof environment variable handling.
// .env.local - Server-only secrets
DATABASE_URL="postgresql://user:pass@localhost:5432/newswave"
JWT_SECRET="your-super-secret-jwt-key-here"
STRIPE_SECRET_KEY="sk_test_..."

// Public variables available in browser
NEXT_PUBLIC_API_URL="https://api.newswave.com"
NEXT_PUBLIC_ANALYTICS_ID="G-XXXXXXXXXX"
Terminal
$ echo "NODE_ENV=production" >> .env.local
Environment variables loaded
✓ Server secrets protected from browser
What just happened?
Environment variables without NEXT_PUBLIC_ stay on the server. The build process never includes them in client bundles. Try this: Add a database URL to your environment file and try to access it in a client component — Next.js will return undefined.
Safe environment variable access requires validation and type checking. Raw process.env access returns strings or undefined, leading to runtime errors when your app expects specific formats.
// lib/env.js - Validated environment config
export const env = {
  // Server-only variables with validation
  DATABASE_URL: process.env.DATABASE_URL || (() => {
    throw new Error('DATABASE_URL is required')
  })(),
  
  JWT_SECRET: process.env.JWT_SECRET || (() => {
    throw new Error('JWT_SECRET is required')
  })()
}
Terminal
$ npm run build
Validating environment variables...
✓ All required variables present
What just happened?
Environment validation runs at import time, catching missing variables before they cause runtime errors. Build processes fail fast instead of deploying broken configurations. Try this: Remove a required environment variable and watch your build fail with a clear error message.

Content Security Policy

Content Security Policy (CSP) acts like a bouncer for your website. It tells browsers which scripts, styles, and resources are allowed to run. Without CSP, any injected script can run with full access to your user's session and data. Modern web applications load resources from multiple sources — CDNs for fonts, analytics scripts, payment processors. CSP lets you create an allowlist of trusted sources while blocking everything else. Think of it as airport security for your web app. Next.js applications need carefully crafted CSP headers because they use inline styles for CSS-in-JS libraries and dynamic script loading for code splitting. Too restrictive and your app breaks. Too permissive and you're vulnerable to XSS attacks.
// next.config.js - CSP headers configuration
const securityHeaders = [
  {
    key: 'Content-Security-Policy',
    value: `
      default-src 'self';
      script-src 'self' 'unsafe-eval' 'unsafe-inline' *.vercel-analytics.com;
      style-src 'self' 'unsafe-inline' fonts.googleapis.com;
      font-src 'self' fonts.gstatic.com;
      img-src 'self' data: https:;
    `.replace(/\s{2,}/g, ' ').trim()
  }
]
Terminal
$ npm run dev
CSP headers applied to all routes
✓ Browser enforcing security policy
What just happened?
CSP headers instruct browsers to only load resources from approved sources. Malicious scripts from untrusted domains get blocked automatically. Try this: Open browser dev tools and check the Network tab for CSP violations when loading external resources.
Advanced CSP implementation uses nonces (numbers used once) for inline scripts and styles. This approach allows legitimate inline content while blocking injected scripts that don't have the correct nonce.
// middleware.js - CSP with nonces
import { NextResponse } from 'next/server'
import crypto from 'crypto'

export function middleware(request) {
  const nonce = crypto.randomBytes(16).toString('base64') // Generate unique nonce
  const csp = `
    default-src 'self';
    script-src 'self' 'nonce-${nonce}' 'strict-dynamic';
    style-src 'self' 'nonce-${nonce}';
  `.replace(/\s{2,}/g, ' ').trim()
  
  const response = NextResponse.next()
  response.headers.set('Content-Security-Policy', csp)
  return response
}
Terminal
$ curl -I http://localhost:3000
Content-Security-Policy: default-src 'self'; script-src 'self' 'nonce-abc123'
✓ Unique nonce generated per request
What just happened?
Each request generates a unique nonce that gets embedded in both the CSP header and inline scripts. Attackers can't guess the nonce, so injected scripts fail to execute. Try this: View page source and notice how the nonce appears in both the CSP header and script tags.

API Route Security

API routes handle sensitive operations like user authentication, payment processing, and data mutations. They're prime targets for attackers because they often have elevated privileges and direct database access. Rate limiting prevents abuse by limiting how many requests a client can make within a time window. Without rate limiting, attackers can brute force passwords, overwhelm your database, or create denial of service conditions for legitimate users. Input validation ensures that API routes receive expected data types and formats. Malicious clients can send unexpected data that crashes your server, corrupts your database, or exposes sensitive information through error messages.
// lib/rate-limit.js - Simple rate limiting
const rateLimits = new Map()

export function rateLimit(ip, limit = 100, window = 3600000) { // 100 requests per hour
  const now = Date.now()
  const windowStart = now - window
  
  if (!rateLimits.has(ip)) {
    rateLimits.set(ip, [])
  }
  
  const requests = rateLimits.get(ip).filter(time => time > windowStart)
  
  if (requests.length >= limit) {
    return false // Rate limited
  }
  
  requests.push(now)
  rateLimits.set(ip, requests)
  return true // Request allowed
}
Terminal
$ node -e "console.log('Rate limiter loaded')"
Rate limiting active on API routes
✓ Preventing abuse and brute force attacks
What just happened?
Rate limiting tracks request timestamps per IP address and blocks excessive requests within the time window. Memory-based storage works for single instances but needs Redis for production scaling. Try this: Make rapid requests to your API and watch rate limiting kick in after the limit.
Input validation should happen at the API boundary before any business logic runs. Zod provides runtime type checking that catches malformed data and provides helpful error messages for debugging.
// app/api/articles/route.js - Validated API route
import { z } from 'zod'
import { rateLimit } from '@/lib/rate-limit'

const createArticleSchema = z.object({
  title: z.string().min(1).max(200),
  content: z.string().min(10),
  category: z.enum(['tech', 'world', 'science', 'business', 'sports']),
  tags: z.array(z.string()).max(10).optional()
})

export async function POST(request) {
  const ip = request.headers.get('x-forwarded-for') || 'unknown'
  
  if (!rateLimit(ip, 10)) { // 10 posts per hour
    return Response.json({ error: 'Rate limited' }, { status: 429 })
  }
  
  try {
    const body = await request.json()
    const validatedData = createArticleSchema.parse(body)
    
    // Safe to use validatedData - it matches our schema
    console.log('Creating article:', validatedData.title)
    
    return Response.json({ success: true })
  } catch (error) {
    return Response.json({ error: 'Invalid data' }, { status: 400 })
  }
}
Terminal
$ curl -X POST -d '{"title":"Test"}' http://localhost:3000/api/articles
{"error":"Invalid data"}
✓ Malformed request rejected safely
What just happened?
Rate limiting and input validation work together to protect API routes. Invalid data gets rejected before reaching business logic, preventing crashes and data corruption. Try this: Send various malformed payloads and observe how validation catches different error types.

Authentication Security

Authentication vulnerabilities are among the most dangerous because they give attackers access to user accounts and sensitive data. Weak session management, predictable tokens, and insecure password handling create opportunities for account takeovers. JSON Web Tokens (JWTs) need careful handling in Next.js applications. Server-side routes can verify tokens securely, but client-side code should never access raw JWT secrets. Token storage location also matters — localStorage persists across browser sessions but remains accessible to XSS attacks. Session management requires balancing security with user experience. Short-lived tokens improve security but require frequent renewal. Long-lived tokens are convenient but increase risk if compromised. Refresh token rotation provides a middle ground.
// lib/auth.js - Secure JWT handling
import jwt from 'jsonwebtoken'
import { cookies } from 'next/headers'

export function generateTokens(userId) {
  const accessToken = jwt.sign(
    { userId, type: 'access' },
    process.env.JWT_SECRET,
    { expiresIn: '15m' } // Short-lived access token
  )
  
  const refreshToken = jwt.sign(
    { userId, type: 'refresh' },
    process.env.JWT_SECRET,
    { expiresIn: '7d' } // Longer-lived refresh token
  )
  
  return { accessToken, refreshToken }
}
Terminal
$ npm install jsonwebtoken bcrypt
Adding secure authentication libraries
✓ JWT and password hashing ready
What just happened?
Short-lived access tokens limit exposure if compromised. Refresh tokens allow seamless renewal without frequent login prompts. The dual-token approach balances security and usability. Try this: Generate tokens and check their expiration times using a JWT decoder.
Secure cookie configuration protects tokens from common attack vectors. HttpOnly prevents JavaScript access, Secure requires HTTPS, and SameSite defends against CSRF attacks. These flags work together to create defense in depth.
// app/api/login/route.js - Secure cookie setting
export async function POST(request) {
  const { email, password } = await request.json()
  
  // Verify credentials (implementation details omitted)
  const user = await verifyCredentials(email, password)
  if (!user) {
    return Response.json({ error: 'Invalid credentials' }, { status: 401 })
  }
  
  const { accessToken, refreshToken } = generateTokens(user.id)
  
  const response = Response.json({ success: true })
  
  // Secure cookie configuration
  response.cookies.set('accessToken', accessToken, {
    httpOnly: true,    // Prevents XSS access
    secure: true,      // HTTPS only
    sameSite: 'strict', // CSRF protection
    maxAge: 15 * 60,   // 15 minutes
    path: '/'
  })
  
  response.cookies.set('refreshToken', refreshToken, {
    httpOnly: true,
    secure: true,
    sameSite: 'strict',
    maxAge: 7 * 24 * 60 * 60, // 7 days
    path: '/'
  })
  
  return response
}
Terminal
$ curl -X POST -d '{"email":"test@news.com","password":"pass"}' http://localhost:3000/api/login
Set-Cookie: accessToken=...; HttpOnly; Secure; SameSite=Strict
✓ Secure authentication cookies set
What just happened?
Secure cookie flags create multiple layers of protection. HttpOnly blocks XSS, Secure requires encrypted connections, and SameSite prevents cross-site attacks. Browser developer tools show these flags in the cookies tab. Try this: Login and inspect cookies in browser dev tools to see security flags applied.

HTTPS and Security Headers

HTTPS encrypts all communication between browsers and your server. Without HTTPS, sensitive data travels in plain text where anyone monitoring network traffic can read passwords, session tokens, and private information. Next.js applications deployed on Vercel get HTTPS automatically. But local development often uses HTTP, creating a disconnect between development and production environments. This mismatch can hide security issues until they reach production. Security headers provide additional protection beyond HTTPS. They tell browsers to enforce security policies, prevent certain types of attacks, and handle sensitive operations more carefully. Modern applications should implement a complete set of security headers.
// next.config.js - Complete security headers
const securityHeaders = [
  {
    key: 'X-DNS-Prefetch-Control',
    value: 'on' // Allow DNS prefetching for performance
  },
  {
    key: 'Strict-Transport-Security',
    value: 'max-age=63072000; includeSubDomains; preload' // Force HTTPS
  },
  {
    key: 'X-Frame-Options',
    value: 'DENY' // Prevent embedding in iframes
  },
  {
    key: 'X-Content-Type-Options',
    value: 'nosniff' // Prevent MIME type sniffing
  },
  {
    key: 'Referrer-Policy',
    value: 'origin-when-cross-origin' // Control referrer information
  }
]

module.exports = {
  async headers() {
    return [
      {
        source: '/(.*)',
        headers: securityHeaders
      }
    ]
  }
}
Terminal
$ npm run build
Security headers configured for all routes
✓ Browser security policies enforced
What just happened?
Security headers provide defense against different attack vectors. HSTS forces HTTPS, X-Frame-Options prevents clickjacking, and X-Content-Type-Options stops MIME sniffing attacks. Try this: Check security headers in browser dev tools Network tab when loading any page.
Local HTTPS development ensures consistency between development and production environments. Self-signed certificates work for development but require browser security warnings to be bypassed.
// package.json - HTTPS development server
{
  "scripts": {
    "dev": "next dev",
    "dev:https": "next dev --experimental-https",
    "build": "next build",
    "start": "next start"
  }
}
Terminal
$ npm run dev:https
Local development server running on https://localhost:3000
✓ HTTPS enabled for development
What just happened?
Local HTTPS development matches production security requirements. Browser warnings about self-signed certificates are normal for development but should never appear in production. Try this: Run HTTPS development and notice how security features like secure cookies now work properly.

Data Sanitization and XSS Prevention

Cross-site scripting (XSS) attacks inject malicious scripts into your application that run with your users' privileges. React provides some XSS protection by default, but developers can accidentally create vulnerabilities through unsafe patterns. React escapes values automatically when rendered as text content. But dangerouslySetInnerHTML bypasses this protection, creating XSS risks if used with unsanitized content. URL handling, dynamic imports, and server-side rendering also create potential attack vectors. Content sanitization removes or encodes dangerous HTML, JavaScript, and other executable content from user input. Different types of content require different sanitization approaches — HTML content needs different handling than URLs or file names.
// lib/sanitize.js - Content sanitization utilities
import DOMPurify from 'dompurify'
import { JSDOM } from 'jsdom'

const window = new JSDOM('').window
const purify = DOMPurify(window)

export function sanitizeHTML(html) {
  return purify.sanitize(html, {
    ALLOWED_TAGS: ['p', 'br', 'strong', 'em', 'ul', 'ol', 'li', 'a'],
    ALLOWED_ATTR: ['href'],
    ALLOW_DATA_ATTR: false
  })
}

export function sanitizeURL(url) {
  try {
    const parsed = new URL(url)
    // Only allow http/https protocols
    if (!['http:', 'https:'].includes(parsed.protocol)) {
      return null
    }
    return parsed.href
  } catch {
    return null
  }
}
Terminal
$ npm install dompurify jsdom
Installing content sanitization libraries
✓ XSS protection tools ready
What just happened?
DOMPurify removes dangerous HTML elements and attributes while preserving safe formatting. URL sanitization prevents javascript: and data: protocol attacks. Try this: Test sanitization by passing malicious HTML like <script>alert('xss')</script> to see it get cleaned.
Safe HTML rendering in components requires sanitization before using dangerouslySetInnerHTML. Never trust user content, even from authenticated users or internal sources.
// components/SafeHTML.js - Secure HTML rendering component
import { sanitizeHTML } from '@/lib/sanitize'

export default function SafeHTML({ content, className = '' }) {
  const cleanHTML = sanitizeHTML(content)
  
  return (
    <div 
      className={className}
      dangerouslySetInnerHTML={{ __html: cleanHTML }}
    />
  )
}

// Usage in article display
export default function ArticleContent({ article }) {
  return (
    <div>
      <h1>{article.title}</h1> {/* Safe - React escapes automatically */}
      <SafeHTML content={article.body} className="prose" /> {/* Sanitized HTML */}
    </div>
  )
}
localhost:3000 — NewsWave
What just happened?
Content sanitization removes dangerous script tags while preserving safe HTML formatting. The SafeHTML component provides a reusable wrapper for secure HTML rendering. Try this: Inspect the rendered HTML to see how malicious scripts were stripped while keeping safe formatting intact.
Security Best Practices Summary
Environment variables should be validated and prefixed correctly. API routes need rate limiting and input validation. Authentication requires secure tokens and HTTP-only cookies. Content must be sanitized before rendering as HTML. Security headers provide defense in depth against multiple attack vectors.

Quiz

1. The NewsWave team needs to add a Google Analytics ID to their app. How should environment variables be handled in Next.js?


2. What cookie configuration provides the best security for NewsWave's authentication tokens?


3. How should NewsWave safely render user-generated HTML content in article comments?


Up Next: Next.js Interview Questions

Master common Next.js interview questions and technical challenges to land your next role with confidence.