Robin te Hofstee

Securing a Next.js Application in Production

Robin te Hofstee8 min readViews6
Securing a Next.js Application in Production

Securing a Next.js app in production means protecting the client bundle, the server, and the data flow. This post shows how to lock down Content‑Secur

Securing a Next.js Application in Production

Securing a Next.js app in production means protecting the client bundle, the server, and the data flow. This post shows how to lock down Content‑Security‑Policy, set security headers, configure secure cookies, protect API routes, keep secrets out of the client bundle, and add basic rate limiting — all using the new App Router.

Content Security Policy and Security Headers

Content‑Security‑Policy (CSP) restricts the sources the browser can load from, mitigating XSS and data‑injection attacks. Combine CSP with other headers for a defense‑in‑depth posture.

Define CSP and other headers in middleware

Create a file middleware.ts at the project root:

// middleware.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
 
export function middleware(request: NextRequest) {
  const response = NextResponse.next();
 
  // CSP – allow only scripts and styles from self; inline allowed for dev only
  const csp = process.env.NODE_ENV === 'production'
    ? "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline';"
    : "default-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'";
 
  response.headers.set('Content-Security-Policy', csp);
  response.headers.set('X-Content-Type-Options', 'nosniff');
  response.headers.set('X-Frame-Options', 'DENY');
  response.headers.set('Referrer-Policy', 'strict-origin-when-cross-origin');
 
  return response;
}
 
// Apply to all routes; adjust matcher as needed
export const config = {
  matcher: '/:path*',
};

⚠️ Warning: Never use destructive commands like rm -rf / in production scripts. Verify every command before execution.

Reference table – security headers

HeaderRecommended ValuePurpose
Content-Security-Policydefault-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline';Restricts script/style sources
X-Content-Type-OptionsnosniffPrevents MIME type sniffing
X-Frame-OptionsDENYStops clickjacking
Referrer-Policystrict-origin-when-cross-originControls referrer leakage
X-XSS-Protection0 (modern browsers ignore)Legacy XSS filter

Cookies that carry session data must be HttpOnly, Secure, and SameSite to prevent client‑side theft and CSRF.

// app/page.tsx (App Router)
import { cookies } from 'next/headers';
 
export default function Home() {
  const cookieStore = cookies();
 
  // Example: set a session cookie after login
  cookieStore.set('session', 'jwt-token', {
    httpOnly: true,
    secure: true,               // only over HTTPS
    sameSite: 'lax',            // prevents CSRF
    path: '/',
    maxAge: 60 * 60 * 24 * 30, // 30 days
  });
 
  return <p>Welcome!</p>;
}
// app/api/login/route.ts
import { NextResponse } from 'next/server';
 
export async function POST(request: Request) {
  const { username, password } = await request.json();
 
  // ...authentication logic...
 
  const token = generateJwtToken(username); // placeholder
 
  const response = NextResponse.json({ success: true });
  response.headers.set(
    'Set-Cookie',
    `session=${token}; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=3600`
  );
  return response;
}
OptionDescription
httpOnlyPrevents JavaScript access (protects from XSS)
secureSent only over HTTPS
sameSiteLax (default) or Strict (prevents CSRF)
pathURL path the cookie applies to (usually /)
maxAgeLifetime in seconds; set to 0 for session cookie

Protecting API Routes from Unauthorized Access

Use middleware to guard API routes or check the token inside the handler.

Middleware‑based protection

// middleware.ts (add to existing file)
export function middleware(request: NextRequest) {
  const token = request.cookies.get('session')?.value;
  const isProtected = request.nextUrl.pathname.startsWith('/api/');
 
  if (isProtected && !token) {
    return new Response('Unauthorized', { status: 401 });
  }
 
  return NextResponse.next();
}

API route that validates a JWT

// app/api/profile/route.ts
import { NextResponse } from 'next/server';
import { verify } from 'jsonwebtoken';
 
export async function GET(request: Request) {
  const authHeader = request.headers.get('authorization');
  if (!authHeader) return new NextResponse('Missing token', { status: 401 });
 
  const token = authHeader.split(' ')[1];
  try {
    const payload = verify(token, process.env.JWT_SECRET!);
    return NextResponse.json({ user: payload });
  } catch {
    return new NextResponse('Invalid token', { status: 401 });
  }
}

⚠️ Warning: Do not expose process.env.JWT_SECRET to the client; it must stay server‑only.

Safe Handling of Environment Variables and Secrets

Next.js distinguishes between public (NEXT_PUBLIC_*) and server‑only variables.

Example .env.local

# Public – safe to expose to the browser
NEXT_PUBLIC_API_URL=https://api.example.com

# Server‑only – never prefixed, never bundled
JWT_SECRET=supersecretvalue
DB_PASSWORD=prodPassword123

Accessing secrets in server code

// app/api/db/route.ts
import { NextResponse } from 'next/server';
 
export async function GET() {
  const dbPassword = process.env.DB_PASSWORD;
  if (!dbPassword) {
    throw new Error('DB_PASSWORD not set');
  }
 
  // Use dbPassword to connect to the database
  // ...
  return NextResponse.json({ status: 'ok' });
}

⚠️ Warning: Accidentally committing .env files or exposing NEXT_PUBLIC_* variables can leak secrets. Keep .env* out of version control (.gitignore).

Table – env var prefixes

PrefixAccessible fromTypical use
NEXT_PUBLIC_Client bundleURLs, feature flags, public config
(none) / server‑onlyServer onlySecrets, DB credentials, JWT signing key

Basic Rate Limiting on Public Endpoints

A simple in‑memory rate limiter per IP works for low‑traffic public APIs. For production, consider a distributed store like Redis.

In‑memory limiter middleware

// middleware.ts (add to existing file)
const rateLimits = new Map<string, number[]>();
 
export function middleware(request: NextRequest) {
  const ip = request.headers.get('x-forwarded-for')?.split(',')[0] ?? request.socket.remoteAddress;
  const now = Date.now();
  const limit = 30; // max requests
  const windowMs = 60 * 1000; // 1 minute
 
  const requests = rateLimits.get(ip) ?? [];
  const recent = requests.filter(t => now - t < windowMs);
  if (recent.length >= limit) {
    return new Response('Too Many Requests', { status: 429 });
  }
  recent.push(now);
  rateLimits.set(ip, recent);
  return NextResponse.next();
}

⚠️ Warning: In‑memory maps lose state on serverless cold starts; for robust rate limiting in serverless environments, use a distributed store (Redis, DynamoDB, etc.).

Table – rate‑limit parameters

ParameterDescription
windowMsDuration of the window (ms)
maxMaximum requests allowed per window
messageResponse body sent when limit exceeded

Summary

  • CSP & headers: Define a strict CSP in middleware, add X‑Content‑Type‑Options, X‑Frame‑Options, Referrer‑Policy, etc.
  • Secure cookies: Use HttpOnly, Secure, and SameSite flags; set them in server components or API routes.
  • API protection: Guard routes with middleware or validate JWTs inside handlers; never expose secrets to the client.
  • Environment variables: Keep secrets out of NEXT_PUBLIC_ prefixes; load them only in server‑side code.
  • Rate limiting: Simple in‑memory limiter works for prototypes; production should use a distributed store.

Applying these practices will significantly harden your Next.js application against common production threats.

Share this post
Robin te Hofstee

Written by Robin te Hofstee

Technology enthusiast with a deep love for programming. Sharing practical guides on React, Next.js, Linux and self-hosting.

How Git Works

3

Git is a distributed version control system that allows multiple people to collaborate on a project while maintaining a complete history of all change