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
| Header | Recommended Value | Purpose |
|---|---|---|
| Content-Security-Policy | default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; | Restricts script/style sources |
| X-Content-Type-Options | nosniff | Prevents MIME type sniffing |
| X-Frame-Options | DENY | Stops clickjacking |
| Referrer-Policy | strict-origin-when-cross-origin | Controls referrer leakage |
| X-XSS-Protection | 0 (modern browsers ignore) | Legacy XSS filter |
Secure Cookie and Session Configuration
Cookies that carry session data must be HttpOnly, Secure, and SameSite to prevent client‑side theft and CSRF.
Setting a secure cookie in a server component
// 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>;
}API route that sets a cookie
// 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;
}Reference table – cookie options
| Option | Description |
|---|---|
| httpOnly | Prevents JavaScript access (protects from XSS) |
| secure | Sent only over HTTPS |
| sameSite | Lax (default) or Strict (prevents CSRF) |
| path | URL path the cookie applies to (usually /) |
| maxAge | Lifetime 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
| Prefix | Accessible from | Typical use |
|---|---|---|
NEXT_PUBLIC_ | Client bundle | URLs, feature flags, public config |
| (none) / server‑only | Server only | Secrets, 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
| Parameter | Description |
|---|---|
| windowMs | Duration of the window (ms) |
| max | Maximum requests allowed per window |
| message | Response 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, andSameSiteflags; 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.

Written by Robin te Hofstee
Technology enthusiast with a deep love for programming. Sharing practical guides on React, Next.js, Linux and self-hosting.
Related Posts
How Git Works
Git is a distributed version control system that allows multiple people to collaborate on a project while maintaining a complete history of all change
How to Check Disk Usage and Find Large Files on Linux
After setting up a new Linux server, you’ll want to know how to check what’s taking up space. Disk space is often the weakest link in system stability
Securing Apache2 Web Server Against Attacks
This tutorial provides a step-by-step guide to securing your Apache2 web server against common vulnerabilities and attacks. Follow these best practice


