Secure Coding Practices for Next.js Applications
You can ship a fast Next.js app that looks great and still be one bad input away from a data breach. This article walks through the security habits I try to bake into every project: how I treat user input, where authentication usually goes wrong, and a few low‑effort wins that make attackers’ lives harder.

Security has a bad reputation as something you sprinkle on at the end of the project when everyone is already exhausted. In reality the cheap, high‑impact fixes are the ones you design for from the start. With Next.js that means understanding which parts of your code run on the server, which run on the client, and how data flows between them.
Input Validation and Sanitization

The golden rule: anything that comes from a user is guilty until proven otherwise. Treat every field, header, and query string as untrusted. Runtime validation libraries like Zod make it easy to be strict without writing piles of boilerplate, and the important part is that the final check happens on the server, not just in your React forms.
import { z } from 'zod';
const userSchema = z.object({
email: z.string().email(),
password: z.string().min(8).regex(/[A-Z]/).regex(/[0-9]/),
age: z.number().int().min(18).max(120),
});
// In your API route
export async function POST(req: Request) {
try {
const body = await req.json();
const validated = userSchema.parse(body);
// Now safe to use validated data
} catch (error) {
return new Response('Invalid input', { status: 400 });
}
}Authentication and Authorization

For authentication, reinventing the wheel is almost always a mistake. Lean on battle‑tested libraries or patterns instead of hand‑rolling your own token system at 2 a.m. Whatever approach you use, passwords should be hashed with something modern like bcrypt or Argon2, tokens should expire, and users should have a way to kill sessions when things feel off.
Protecting API Routes
Next.js API routes feel "internal", but to the outside world they’re just normal HTTP endpoints. Give them the same respect you’d give any public API: add rate limiting, validate input properly, be deliberate about CORS, and keep anything secret in environment variables instead of shipping it to the browser by accident.
// Secure API route example
import { Ratelimit } from '@upstash/ratelimit';
import { Redis } from '@upstash/redis';
const ratelimit = new Ratelimit({
redis: Redis.fromEnv(),
limiter: Ratelimit.slidingWindow(100, '15 m'),
});
export async function POST(req: Request) {
// Apply rate limiting
const ip = req.headers.get('x-forwarded-for') || 'unknown';
const { success } = await ratelimit.limit(ip);
if (!success) {
return new Response('Too many requests', { status: 429 });
}
// Verify authentication
const session = await getServerSession();
if (!session) return new Response('Unauthorized', { status: 401 });
// Validate input
const data = await validateInput(req);
// Process request
return new Response(JSON.stringify({ success: true }));
}Security Headers
Finally, don’t sleep on security headers. A decent Content‑Security‑Policy, X‑Frame‑Options, HSTS, and a few other headers close off entire classes of cheap attacks. They’re not glamorous, but they’re the sort of boring configuration that pays for itself the first time something weird hits your logs.
- Content-Security-Policy: Prevents XSS attacks
- X-Frame-Options: Prevents clickjacking
- Strict-Transport-Security: Forces HTTPS
- X-Content-Type-Options: Prevents MIME sniffing
