Cybersecurity & Ethical Hacking
API Security in Next.js: Common Vulnerabilities and How to Fix Them
TL;DR
Most Next.js API routes I audit have at least three of these problems: no authentication check, no input validation, no rate limiting, and direct database queries built from user input. The framework gives you a clean API layer with Route Handlers and Server Actions, but it gives you zero security by default. Every route is publicly accessible unless you lock it down yourself. This article walks through the nine most common API security vulnerabilities I find in Next.js projects, with real vulnerable code patterns and their production-grade fixes. Bookmark the checklist at the end — I run through it on every project before anything goes to production.
The Most Common API Vulnerabilities I Find
I review API security on every project I touch. Whether it is a client engagement, an open-source audit, or my own applications — the process is the same. And after doing this across dozens of Next.js codebases, the pattern is depressingly consistent.
Here is what I find, ranked roughly by how often I see each one:
- Missing authentication on routes that should be protected
- IDOR vulnerabilities where users can access other users' data by changing an ID
- No input validation — query parameters and request bodies go straight into business logic
- No rate limiting — every endpoint is a free-for-all
- SQL injection through string concatenation in database queries
- Misconfigured CORS that allows any origin
- No request size limits — endpoints accept unlimited payloads
- Zero logging — when something goes wrong, there is no trail to follow
The common thread is that Next.js gives you the tools to build APIs quickly, but it does not enforce any security patterns. Route Handlers are just functions. Server Actions are just functions. Neither comes with middleware that checks a session, validates input, or limits request frequency. That is all on you.
Let me walk through each one with the vulnerable code I actually see and the fix I actually apply.
Authentication — Every Route Needs It
This is the single most common vulnerability I find. A developer creates a Route Handler to fetch user data, and they forget — or do not realize — that the endpoint is publicly accessible by default.
The vulnerable code
// app/api/users/[id]/route.ts
// VULNERABLE: No authentication check
import { db } from "@/lib/db";
import { NextResponse } from "next/server";
export async function GET(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params;
const user = await db.user.findUnique({
where: { id },
select: { id: true, email: true, name: true, role: true },
});
return NextResponse.json(user);
}Anyone can hit /api/users/any-user-id and get back the user's email, name, and role. No session check. No token verification. Nothing.
The fix
// app/api/users/[id]/route.ts
// FIXED: Authentication required
import { auth } from "@/lib/auth";
import { db } from "@/lib/db";
import { NextResponse } from "next/server";
export async function GET(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const session = await auth();
if (!session?.user) {
return NextResponse.json(
{ code: "UNAUTHORIZED", message: "Authentication required" },
{ status: 401 }
);
}
const { id } = await params;
const user = await db.user.findUnique({
where: { id },
select: { id: true, email: true, name: true, role: true },
});
if (!user) {
return NextResponse.json(
{ code: "NOT_FOUND", message: "User not found" },
{ status: 404 }
);
}
return NextResponse.json(user);
}The pattern is simple: check the session at the top of every Route Handler. If there is no session, return a 401 immediately. Do not let execution proceed to the database query.
I use a wrapper function to avoid repeating this in every route:
// lib/api-auth.ts
import { auth } from "@/lib/auth";
import { NextResponse } from "next/server";
type AuthenticatedHandler = (
request: Request,
session: Session,
context: Record<string, unknown>
) => Promise<NextResponse>;
export function withAuth(handler: AuthenticatedHandler) {
return async (request: Request, context: Record<string, unknown>) => {
const session = await auth();
if (!session?.user) {
return NextResponse.json(
{ code: "UNAUTHORIZED", message: "Authentication required" },
{ status: 401 }
);
}
return handler(request, session, context);
};
}Now every protected route is a one-liner to secure. No more forgetting.
Authorization — IDOR Is Everywhere
Authentication checks whether the user is logged in. Authorization checks whether the user is allowed to do what they are trying to do. Missing authorization leads to Insecure Direct Object Reference — IDOR — and it is the vulnerability I find most frequently after missing auth.
The vulnerable code
// app/api/orders/[id]/route.ts
// VULNERABLE: Authenticated but no authorization check
export async function GET(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const session = await auth();
if (!session?.user) {
return NextResponse.json({ code: "UNAUTHORIZED" }, { status: 401 });
}
const { id } = await params;
// Any authenticated user can view ANY order by changing the ID
const order = await db.order.findUnique({
where: { id },
include: { items: true, shippingAddress: true },
});
return NextResponse.json(order);
}The developer added authentication. But user A can still view user B's orders by guessing or iterating over order IDs. If the IDs are sequential integers, this is trivially exploitable. Even with UUIDs, a leaked ID in a URL or log exposes the order.
The fix
// app/api/orders/[id]/route.ts
// FIXED: Authorization check ensures ownership
export async function GET(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const session = await auth();
if (!session?.user) {
return NextResponse.json({ code: "UNAUTHORIZED" }, { status: 401 });
}
const { id } = await params;
const order = await db.order.findUnique({
where: {
id,
userId: session.user.id, // Scoped to the authenticated user
},
include: { items: true, shippingAddress: true },
});
if (!order) {
return NextResponse.json(
{ code: "NOT_FOUND", message: "Order not found" },
{ status: 404 }
);
}
return NextResponse.json(order);
}The key difference is adding userId: session.user.id to the where clause. The query only returns the order if it belongs to the authenticated user. If user A tries to access user B's order, the query returns null, and they get a 404.
Notice I return 404, not 403. Returning 403 ("forbidden") confirms that the resource exists but the user lacks access. Returning 404 reveals nothing — the attacker does not know if the order ID is invalid or belongs to someone else.
For admin routes that need to access any user's data, I add a role check:
if (session.user.role !== "ADMIN" && order.userId !== session.user.id) {
return NextResponse.json(
{ code: "NOT_FOUND", message: "Order not found" },
{ status: 404 }
);
}Input Validation with Zod
Unvalidated input is the root cause of half the vulnerabilities on this list. If you do not validate what comes in, you cannot trust anything your API does with it.
The vulnerable code
// app/api/products/route.ts
// VULNERABLE: No input validation
export async function POST(request: Request) {
const session = await auth();
if (!session?.user) {
return NextResponse.json({ code: "UNAUTHORIZED" }, { status: 401 });
}
const body = await request.json();
// Directly using unvalidated input
const product = await db.product.create({
data: {
name: body.name,
price: body.price,
description: body.description,
category: body.category,
userId: session.user.id,
},
});
return NextResponse.json(product, { status: 201 });
}What happens when body.price is a negative number? Or a string? Or when body.name is an empty string? Or when someone sends body.role: "ADMIN" and your Prisma schema auto-maps it? You are trusting the client to send well-formed data. The client is not trustworthy.
The fix
// app/api/products/route.ts
// FIXED: Zod validation on all input
import { z } from "zod";
const createProductSchema = z.object({
name: z
.string()
.min(1, "Name is required")
.max(200, "Name must be under 200 characters")
.trim(),
price: z
.number()
.positive("Price must be positive")
.max(999999.99, "Price exceeds maximum"),
description: z
.string()
.max(5000, "Description must be under 5000 characters")
.trim()
.optional(),
category: z.enum(["electronics", "clothing", "food", "other"]),
});
export async function POST(request: Request) {
const session = await auth();
if (!session?.user) {
return NextResponse.json({ code: "UNAUTHORIZED" }, { status: 401 });
}
const body = await request.json();
const result = createProductSchema.safeParse(body);
if (!result.success) {
return NextResponse.json(
{
code: "VALIDATION_ERROR",
message: "Invalid input",
details: result.error.flatten().fieldErrors,
},
{ status: 400 }
);
}
const product = await db.product.create({
data: {
...result.data,
userId: session.user.id,
},
});
return NextResponse.json(product, { status: 201 });
}Zod validates the type, shape, and constraints of every field before it reaches the database. The safeParse method returns a discriminated union — either the validated data or a structured error. No exceptions, no try-catch for validation logic, and the error response includes field-level details so the client knows exactly what went wrong.
I define schemas in a shared /lib/validations directory so they can be reused between API routes and client-side form validation. One schema, two environments, zero drift.
Rate Limiting
Without rate limiting, every API endpoint is an open invitation for abuse. Brute-force attacks on login endpoints, credential stuffing, data scraping, denial-of-service through expensive queries — all of these are trivially easy when there are no request limits.
The vulnerable code
// app/api/auth/login/route.ts
// VULNERABLE: No rate limiting
export async function POST(request: Request) {
const { email, password } = await request.json();
const user = await db.user.findUnique({ where: { email } });
if (!user || !await verifyPassword(password, user.passwordHash)) {
return NextResponse.json(
{ code: "INVALID_CREDENTIALS", message: "Invalid email or password" },
{ status: 401 }
);
}
const token = await createSession(user.id);
return NextResponse.json({ token });
}An attacker can send thousands of login attempts per second. No lockout. No delay. No detection.
The fix
// lib/rate-limit.ts
import { LRUCache } from "lru-cache";
type RateLimitOptions = {
interval: number;
uniqueTokenPerInterval: number;
};
export function rateLimit(options: RateLimitOptions) {
const tokenCache = new LRUCache<string, number[]>({
max: options.uniqueTokenPerInterval,
ttl: options.interval,
});
return {
check(limit: number, token: string): { success: boolean; remaining: number } {
const tokenCount = tokenCache.get(token) ?? [0];
const currentUsage = tokenCount[0];
if (currentUsage >= limit) {
return { success: false, remaining: 0 };
}
tokenCache.set(token, [currentUsage + 1]);
return { success: true, remaining: limit - currentUsage - 1 };
},
};
}
// app/api/auth/login/route.ts
// FIXED: Rate limited to 5 attempts per minute per IP
import { rateLimit } from "@/lib/rate-limit";
import { headers } from "next/headers";
const limiter = rateLimit({
interval: 60 * 1000, // 1 minute
uniqueTokenPerInterval: 500,
});
export async function POST(request: Request) {
const headersList = await headers();
const ip = headersList.get("x-forwarded-for") ?? "127.0.0.1";
const { success, remaining } = limiter.check(5, ip);
if (!success) {
return NextResponse.json(
{ code: "RATE_LIMITED", message: "Too many requests. Try again later." },
{
status: 429,
headers: { "Retry-After": "60" },
}
);
}
const body = await request.json();
const { email, password } = body;
const user = await db.user.findUnique({ where: { email } });
if (!user || !await verifyPassword(password, user.passwordHash)) {
return NextResponse.json(
{ code: "INVALID_CREDENTIALS", message: "Invalid email or password" },
{ status: 401 }
);
}
const token = await createSession(user.id);
return NextResponse.json(
{ token },
{ headers: { "X-RateLimit-Remaining": String(remaining) } }
);
}For production applications with multiple instances, I use Redis-backed rate limiting with @upstash/ratelimit instead of the in-memory LRU cache. The in-memory approach works for single-instance deployments and development, but it does not share state across serverless function invocations on Vercel. Upstash's Redis integration is purpose-built for this:
import { Ratelimit } from "@upstash/ratelimit";
import { Redis } from "@upstash/redis";
const ratelimit = new Ratelimit({
redis: Redis.fromEnv(),
limiter: Ratelimit.slidingWindow(5, "60 s"),
analytics: true,
});Different endpoints deserve different limits. Login gets 5 per minute. General API reads might get 100 per minute. Expensive operations like report generation might get 3 per hour. Match the limit to the operation cost.
SQL Injection Prevention
If you are using Prisma or any ORM with parameterized queries, you are mostly protected from SQL injection by default. The danger comes when developers bypass the ORM for "complex" queries and reach for raw SQL.
The vulnerable code
// app/api/search/route.ts
// VULNERABLE: String concatenation in raw SQL
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const query = searchParams.get("q") ?? "";
// NEVER DO THIS
const results = await db.$queryRawUnsafe(
`SELECT * FROM products WHERE name LIKE '%${query}%'`
);
return NextResponse.json(results);
}An attacker sends q=' OR 1=1; DROP TABLE products; -- and your products table ceases to exist. This is the most preventable vulnerability in existence, and I still find it in production code.
The fix
// app/api/search/route.ts
// FIXED: Parameterized query
import { Prisma } from "@prisma/client";
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const query = searchParams.get("q") ?? "";
// Validate and sanitize the search term
const sanitizedQuery = query.slice(0, 100).trim();
if (sanitizedQuery.length < 2) {
return NextResponse.json(
{ code: "VALIDATION_ERROR", message: "Search query must be at least 2 characters" },
{ status: 400 }
);
}
// Option 1: Use Prisma's built-in filtering (preferred)
const results = await db.product.findMany({
where: {
name: { contains: sanitizedQuery, mode: "insensitive" },
},
take: 20,
});
// Option 2: If you MUST use raw SQL, use parameterized queries
// const results = await db.$queryRaw(
// Prisma.sql`SELECT id, name, price FROM products
// WHERE name ILIKE ${`%${sanitizedQuery}%`}
// LIMIT 20`
// );
return NextResponse.json(results);
}The rule is absolute: never concatenate user input into a SQL string. Use Prisma's query builder or parameterized queries with Prisma.sql tagged template literals. The tagged template automatically escapes parameters. $queryRawUnsafe should not exist in your codebase — if I find it during a review, it is an immediate red flag.
CORS Configuration
Cross-Origin Resource Sharing misconfigurations are subtle. The API works fine during development, and the developer either does not set CORS headers (relying on same-origin) or sets them to allow everything. Both are problems.
The vulnerable code
// middleware.ts
// VULNERABLE: Wildcard CORS in production
import { NextResponse } from "next/server";
export function middleware(request: Request) {
const response = NextResponse.next();
// This allows ANY website to call your API
response.headers.set("Access-Control-Allow-Origin", "*");
response.headers.set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE");
response.headers.set("Access-Control-Allow-Headers", "Content-Type, Authorization");
return response;
}With wildcard CORS, a malicious site can make authenticated requests to your API from a victim's browser, read the responses, and exfiltrate data.
The fix
// middleware.ts
// FIXED: Explicit origin allowlist
import { NextResponse } from "next/server";
const ALLOWED_ORIGINS = [
"https://yourapp.com",
"https://www.yourapp.com",
process.env.NODE_ENV === "development" ? "http://localhost:3000" : "",
].filter(Boolean);
export function middleware(request: Request) {
const origin = request.headers.get("origin") ?? "";
const response = NextResponse.next();
if (ALLOWED_ORIGINS.includes(origin)) {
response.headers.set("Access-Control-Allow-Origin", origin);
response.headers.set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS");
response.headers.set("Access-Control-Allow-Headers", "Content-Type, Authorization");
response.headers.set("Access-Control-Allow-Credentials", "true");
response.headers.set("Access-Control-Max-Age", "86400");
}
if (request.method === "OPTIONS") {
return new NextResponse(null, { status: 204, headers: response.headers });
}
return response;
}
export const config = {
matcher: "/api/:path*",
};Explicit origins. No wildcards. The Access-Control-Allow-Credentials: true header is included because most authenticated APIs need it, and it is incompatible with wildcard origins anyway — browsers will reject the response if you try to use both. The preflight cache (Max-Age: 86400) reduces the number of OPTIONS requests the browser sends.
Request Size Limits
Next.js does not enforce request body size limits by default. Without them, an attacker can send a 500MB JSON payload to your API and exhaust your serverless function's memory, cause a denial-of-service, or simply rack up your hosting bill.
The vulnerable code
// app/api/upload/route.ts
// VULNERABLE: No size limit on request body
export async function POST(request: Request) {
const body = await request.json(); // Will attempt to parse ANY size payload
// process body...
return NextResponse.json({ success: true });
}The fix
// app/api/upload/route.ts
// FIXED: Enforce request size limits
export const runtime = "nodejs";
const MAX_BODY_SIZE = 1024 * 1024; // 1MB
export async function POST(request: Request) {
// Check Content-Length header first (fast reject)
const contentLength = parseInt(request.headers.get("content-length") ?? "0", 10);
if (contentLength > MAX_BODY_SIZE) {
return NextResponse.json(
{ code: "PAYLOAD_TOO_LARGE", message: "Request body exceeds 1MB limit" },
{ status: 413 }
);
}
// Stream-based check for cases where Content-Length is missing or spoofed
const reader = request.body?.getReader();
if (!reader) {
return NextResponse.json(
{ code: "BAD_REQUEST", message: "Request body is required" },
{ status: 400 }
);
}
const chunks: Uint8Array[] = [];
let totalSize = 0;
while (true) {
const { done, value } = await reader.read();
if (done) break;
totalSize += value.byteLength;
if (totalSize > MAX_BODY_SIZE) {
reader.cancel();
return NextResponse.json(
{ code: "PAYLOAD_TOO_LARGE", message: "Request body exceeds 1MB limit" },
{ status: 413 }
);
}
chunks.push(value);
}
const bodyText = new TextDecoder().decode(
Buffer.concat(chunks.map((chunk) => Buffer.from(chunk)))
);
const body = JSON.parse(bodyText);
// process validated body...
return NextResponse.json({ success: true });
}You can also set the limit in next.config.ts for all routes:
// next.config.ts
const nextConfig = {
experimental: {
serverActions: {
bodySizeLimit: "1mb",
},
},
};
export default nextConfig;For file upload endpoints, the limit will be higher — but it should still exist. I set upload limits based on the actual requirements: profile avatars get 2MB, document uploads get 10MB, and nothing gets unlimited.
Logging and Monitoring
The absence of logging is not technically a vulnerability in itself, but it makes every other vulnerability worse. When there is no audit trail, you cannot detect attacks in progress, you cannot investigate incidents after the fact, and you cannot prove what happened to clients or regulators.
The vulnerable code
// app/api/admin/users/route.ts
// VULNERABLE: No logging on sensitive operations
export async function DELETE(request: Request) {
const session = await auth();
if (session?.user.role !== "ADMIN") {
return NextResponse.json({ code: "FORBIDDEN" }, { status: 403 });
}
const { userId } = await request.json();
await db.user.delete({ where: { id: userId } });
return NextResponse.json({ success: true });
}An admin deletes a user account. No record of who did it, when, or why. If the admin's account was compromised, there is zero evidence trail.
The fix
// lib/logger.ts
type AuditEvent = {
action: string;
actorId: string;
targetId: string;
targetType: string;
metadata?: Record<string, unknown>;
ip: string;
userAgent: string;
};
export async function logAuditEvent(event: AuditEvent) {
await db.auditLog.create({
data: {
action: event.action,
actorId: event.actorId,
targetId: event.targetId,
targetType: event.targetType,
metadata: event.metadata ?? {},
ipAddress: event.ip,
userAgent: event.userAgent,
timestamp: new Date(),
},
});
// Also log to structured logging service (Axiom, Datadog, etc.)
console.log(
JSON.stringify({
level: "info",
type: "audit",
...event,
timestamp: new Date().toISOString(),
})
);
}
// app/api/admin/users/route.ts
// FIXED: Full audit logging on sensitive operations
import { logAuditEvent } from "@/lib/logger";
export async function DELETE(request: Request) {
const session = await auth();
if (session?.user.role !== "ADMIN") {
return NextResponse.json({ code: "FORBIDDEN" }, { status: 403 });
}
const headersList = await headers();
const { userId } = await request.json();
await db.user.delete({ where: { id: userId } });
await logAuditEvent({
action: "USER_DELETED",
actorId: session.user.id,
targetId: userId,
targetType: "User",
ip: headersList.get("x-forwarded-for") ?? "unknown",
userAgent: headersList.get("user-agent") ?? "unknown",
});
return NextResponse.json({ success: true });
}Every sensitive operation — user deletion, role changes, permission modifications, data exports — gets an audit log entry. I log the actor, the action, the target, the IP address, and a timestamp. This data is stored in the database for querying and forwarded to a structured logging service for alerting.
The minimum I log on every project:
- Authentication events — login, logout, failed attempts, password resets
- Authorization failures — every 403 response
- Data mutations — creates, updates, deletes on sensitive resources
- Admin actions — anything done through admin endpoints
- Rate limit hits — patterns of rate-limited requests indicate active attacks
My API Security Checklist
I run through this checklist before every deployment. It takes ten minutes and catches the majority of the vulnerabilities described in this article.
Authentication
- [ ] Every Route Handler that accesses user data checks for a valid session
- [ ] Server Actions verify authentication before performing mutations
- [ ] Tokens have appropriate expiry times (short-lived access, long-lived refresh)
- [ ] Failed authentication returns consistent error messages (no user enumeration)
Authorization
- [ ] Every database query that fetches user-specific data is scoped to the authenticated user's ID
- [ ] Admin endpoints verify role before execution
- [ ] 404 is returned instead of 403 to avoid confirming resource existence
- [ ] Vertical and horizontal privilege escalation has been tested
Input Validation
- [ ] Every request body is validated with Zod before processing
- [ ] Query parameters are validated for type, length, and format
- [ ] File uploads are validated for type, size, and content (not just extension)
- [ ] Enum fields use strict validation — no arbitrary string values
Rate Limiting
- [ ] Login endpoints: 5 requests per minute per IP
- [ ] General API: 100 requests per minute per user
- [ ] Expensive operations: appropriate per-operation limits
- [ ] 429 responses include
Retry-Afterheader
Injection Prevention
- [ ] No
$queryRawUnsafecalls in the codebase - [ ] All database queries use ORM methods or parameterized raw queries
- [ ] User input is never concatenated into SQL, HTML, or shell commands
CORS
- [ ] Explicit origin allowlist in production — no wildcards
- [ ] Preflight requests handled with appropriate caching
- [ ] Credentials flag set correctly
Request Limits
- [ ] Body size limits configured globally and per-route where needed
- [ ] File upload limits match actual requirements
- [ ] Pagination limits prevent unbounded data fetches (
take: 100max)
Logging
- [ ] Authentication events are logged
- [ ] Authorization failures are logged
- [ ] Sensitive data mutations are logged with actor identity
- [ ] Logs are forwarded to a monitoring service with alerts configured
Headers
- [ ]
X-Content-Type-Options: nosniff - [ ]
X-Frame-Options: DENY - [ ]
Strict-Transport-Securityenabled - [ ]
Content-Security-Policyconfigured - [ ]
Referrer-Policy: strict-origin-when-cross-origin
Key Takeaways
Next.js gives you zero API security by default. Route Handlers and Server Actions are publicly accessible functions. Every protection layer — authentication, authorization, validation, rate limiting — is something you build and enforce yourself.
IDOR is the vulnerability I find most often in "secured" applications. Adding authentication is step one. Scoping every database query to the authenticated user's ID is step two. Most developers stop at step one.
Input validation is not optional. Zod schemas at the boundary of every API route. Validate type, shape, length, and business constraints. One schema shared between server and client eliminates drift.
Rate limiting is easier than you think. An in-memory LRU cache works for single-instance deployments. Upstash Redis works for serverless. Either way, five lines of code can prevent brute-force attacks, credential stuffing, and API abuse.
Log everything that matters. When — not if — something goes wrong, the audit trail is the difference between a two-hour investigation and a two-week guessing game.
Run the checklist. Every project. Every deployment. Ten minutes of checking saves weeks of incident response.
The security surface of a Next.js API is large, but the patterns to protect it are consistent. Apply them once, extract them into reusable utilities, and they become part of every project you build from that point forward. Security is not a feature — it is the foundation that makes every other feature trustworthy.
*I am Uvin Vindula↗ — a Web3 and AI engineer based between Sri Lanka and the UK. I build production-grade applications and audit them for security vulnerabilities before they ship. If your Next.js application needs a security review, API hardening, or a full penetration test, let's talk about it.*
Working on a Web3 or AI project?

Uvin Vindula
Web3 and AI engineer based in Sri Lanka and the UK. Author of The Rise of Bitcoin. Director of Blockchain and Software Solutions at Terra Labz. Founder of uvin.lk — Sri Lanka's Bitcoin education platform with 10,000+ learners.