Securing Your Next.js API Routes: Rate Limiting, CORS, Injection Prevention, and Secrets Management
A production-focused deep dive into Next.js API route security: rate limiting with Upstash Redis, CORS configuration, SQL injection prevention with Prisma, XSS mitigation in Server Actions, secrets management with Doppler and Vault, input validation with Zod, and OWASP Top 10 mapping for Next.js applications.
Why API Security in Next.js Deserves More Than a Middleware Afterthought
Next.js has evolved into a full-stack framework. With the App Router, you're shipping API routes, Server Actions, and data-fetching logic — all inside the same repo, often inside the same file. That's powerful, but it collapses the boundary between "frontend" and "backend," and with it, the security assumptions that used to protect your API layer.
In this article, we'll walk through the critical security controls every Next.js application needs: rate limiting with Upstash Redis, proper CORS configuration, SQL injection prevention, XSS mitigation in Server Actions, secrets management, and input validation with Zod. We'll also map these to the OWASP Top 10 so you know what threats you're actually defending against.
This is not a checklist article. We'll go deep on each topic, explain why naive implementations fail, and show you production-ready patterns you can adopt today.
1. Rate Limiting with Upstash Redis
Rate limiting maps directly to OWASP A04: Insecure Design — specifically, failing to account for resource exhaustion as a threat model. An unprotected API route can be abused for credential stuffing, account enumeration, or simply DDoSed into unavailability.
Upstash Redis is perfect for serverless Next.js because it's HTTP-based, has zero persistent connections, and offers a first-class @upstash/ratelimit library.
// lib/ratelimit.ts
import { Ratelimit } from "@upstash/ratelimit";
import { Redis } from "@upstash/redis";
export const ratelimit = new Ratelimit({
redis: Redis.fromEnv(),
limiter: Ratelimit.slidingWindow(10, "10 s"),
analytics: true,
prefix: "@nextapp/ratelimit",
});
Then use it inside your App Router route handler:
// app/api/contact/route.ts
import { NextRequest, NextResponse } from "next/server";
import { ratelimit } from "@/lib/ratelimit";
export async function POST(req: NextRequest) {
const ip = req.headers.get("x-forwarded-for") ?? "127.0.0.1";
const { success, limit, remaining, reset } = await ratelimit.limit(ip);
if (!success) {
return NextResponse.json(
{ error: "Too many requests. Please slow down." },
{
status: 429,
headers: {
"X-RateLimit-Limit": String(limit),
"X-RateLimit-Remaining": String(remaining),
"X-RateLimit-Reset": String(reset),
},
}
);
}
// Handle the request...
return NextResponse.json({ ok: true });
}
A few production tips: use a sliding window algorithm rather than fixed window to prevent burst-at-boundary attacks. Also consider rate-limiting by user ID (from session) in addition to IP — authenticated users should have separate, possibly more generous limits than anonymous traffic.
2. CORS: Stop Treating It as a Browser-Only Problem
CORS misconfigurations fall under OWASP A01: Broken Access Control. A common mistake in Next.js apps is either setting Access-Control-Allow-Origin: * globally (too permissive) or skipping CORS entirely and assuming the Same-Origin Policy will protect you (it won't — Server Actions and API routes can still be hit cross-origin by non-browser clients).
The correct approach in the App Router is to handle CORS manually in your route handlers or via middleware:
// middleware.ts
import { NextRequest, NextResponse } from "next/server";
const ALLOWED_ORIGINS = [
"https://nextfuture.io.vn",
"https://app.nextfuture.io.vn",
];
export function middleware(req: NextRequest) {
const origin = req.headers.get("origin") ?? "";
const isAllowed = ALLOWED_ORIGINS.includes(origin);
// Handle preflight
if (req.method === "OPTIONS") {
return new NextResponse(null, {
status: 204,
headers: {
"Access-Control-Allow-Origin": isAllowed ? origin : "",
"Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type, Authorization",
"Access-Control-Max-Age": "86400",
},
});
}
const res = NextResponse.next();
if (isAllowed) {
res.headers.set("Access-Control-Allow-Origin", origin);
res.headers.set("Vary", "Origin");
}
return res;
}
export const config = {
matcher: "/api/:path*",
};
Notice the Vary: Origin header — this is critical for CDN caching. Without it, a CDN may cache a response with one origin's CORS header and serve it to a different origin.
Also consider adding these security headers globally in next.config.js:
// next.config.js
const securityHeaders = [
{ key: "X-Content-Type-Options", value: "nosniff" },
{ key: "X-Frame-Options", value: "DENY" },
{ key: "Referrer-Policy", value: "strict-origin-when-cross-origin" },
{
key: "Content-Security-Policy",
value: "default-src 'self'; script-src 'self' 'unsafe-inline'",
},
];
module.exports = {
async headers() {
return [{ source: "/:path*", headers: securityHeaders }];
},
};
3. SQL Injection Prevention with Parameterized Queries and Prisma
SQL injection is OWASP A03: Injection — and despite being the oldest attack in the book, it still haunts modern apps. The danger in Next.js is particularly subtle: developers who would never write raw SQL in an Express app sometimes reach for raw queries in Server Actions when they think they "need performance."
The golden rule: never interpolate user input into SQL strings.
// ❌ VULNERABLE — never do this
const user = await db.query(
`SELECT * FROM users WHERE email = '${email}'`
);
// ✅ SAFE — parameterized query
const user = await db.query(
"SELECT * FROM users WHERE email = $1",
[email]
);
If you're using Prisma (which most Next.js apps should be), you get injection safety by default:
// ✅ Prisma — safe by design
const user = await prisma.user.findUnique({
where: { email },
});
// ✅ Raw queries with Prisma — use tagged template literals
const result = await prisma.$queryRaw`
SELECT id, name FROM users WHERE email = ${email}
`;
// ❌ Prisma raw — UNSAFE if you bypass the template literal
const result = await prisma.$queryRawUnsafe(
`SELECT * FROM users WHERE email = '${email}'` // injection possible!
);
The key insight: Prisma's $queryRaw uses tagged template literals, which automatically parameterize values. $queryRawUnsafe bypasses this — treat it like exec in Node.js: only use with fully controlled, static strings.
Beyond SQL injection, also guard against NoSQL injection if you're using MongoDB. Mongoose's findOne({ username: req.body.username }) is vulnerable if username is an object like { "$gt": "" }. Always validate the type of inputs before passing them to queries.
4. Input Validation with Zod — Your First Line of Defense
Input validation addresses multiple OWASP issues at once: A03 (Injection), A04 (Insecure Design), and A08 (Software and Data Integrity Failures). Zod is the go-to solution in the TypeScript/Next.js ecosystem because it gives you runtime type safety on top of compile-time types.
// lib/schemas/contact.ts
import { z } from "zod";
export const ContactSchema = z.object({
name: z.string().min(2).max(100).trim(),
email: z.string().email().toLowerCase(),
message: z.string().min(10).max(2000),
// Prevent injection via subject line
subject: z.string().max(200).regex(/^[a-zA-Z0-9\s\-.,!?]+$/),
});
// app/api/contact/route.ts
import { ContactSchema } from "@/lib/schemas/contact";
export async function POST(req: NextRequest) {
let body: unknown;
try {
body = await req.json();
} catch {
return NextResponse.json({ error: "Invalid JSON" }, { status: 400 });
}
const parsed = ContactSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json(
{ error: "Validation failed", issues: parsed.error.flatten() },
{ status: 422 }
);
}
const { name, email, message, subject } = parsed.data;
// Safe to use — validated and typed
}
For Server Actions, the same pattern applies:
// app/actions/submit-form.ts
"use server";
import { ContactSchema } from "@/lib/schemas/contact";
export async function submitContactForm(formData: FormData) {
const raw = Object.fromEntries(formData);
const parsed = ContactSchema.safeParse(raw);
if (!parsed.success) {
return { error: parsed.error.flatten() };
}
// proceed with parsed.data
}
One critical note: never trust Zod on the client alone. Always re-validate on the server. Client-side Zod is just UX — it can be bypassed entirely by anyone with curl.
5. XSS Prevention in Server Actions and API Responses
XSS (OWASP A03, now reclassified under Injection) is tricky in Next.js because React's JSX escapes output by default — but there are escape hatches that reintroduce risk.
The most dangerous patterns:
dangerouslySetInnerHTML— if you render user-provided content here, you need to sanitize it first- Server Actions returning HTML strings — if these get rendered as raw HTML anywhere, stored XSS is possible
- Markdown rendering — user-provided markdown can contain HTML, which markdown parsers may pass through
// lib/sanitize.ts
import DOMPurify from "isomorphic-dompurify";
export function sanitizeHtml(dirty: string): string {
return DOMPurify.sanitize(dirty, {
ALLOWED_TAGS: ["b", "i", "em", "strong", "a", "p", "br"],
ALLOWED_ATTR: ["href", "target", "rel"],
// Force external links to be safe
FORCE_BODY: true,
});
}
// Usage in a Server Action saving user bio
"use server";
import { sanitizeHtml } from "@/lib/sanitize";
export async function updateUserBio(bio: string) {
const cleanBio = sanitizeHtml(bio);
await prisma.user.update({
where: { id: session.userId },
data: { bio: cleanBio },
});
}
For markdown, use a sanitizing pipeline:
import { unified } from "unified";
import remarkParse from "remark-parse";
import remarkRehype from "remark-rehype";
import rehypeSanitize from "rehype-sanitize";
import rehypeStringify from "rehype-stringify";
export async function markdownToSafeHtml(md: string) {
const result = await unified()
.use(remarkParse)
.use(remarkRehype)
.use(rehypeSanitize) // sanitizes HTML in the pipeline
.use(rehypeStringify)
.process(md);
return String(result);
}
6. Secrets Management: Beyond .env Files
Secrets mismanagement is OWASP A02: Cryptographic Failures and A07: Identification and Authentication Failures. Leaking a database URL or API key in a public repo (yes, it still happens) can be catastrophic.
The .env Hierarchy in Next.js
Next.js loads environment variables in this order (later files override earlier ones):
.env— defaults, safe to commit for non-sensitive config.env.local— local overrides, never commit.env.development/.env.production— environment-specific
Critical rule: variables prefixed with NEXT_PUBLIC_ are bundled into the client. Never put secrets there. Only values safe for the browser should be public.
# .env
NEXT_PUBLIC_APP_URL=https://nextfuture.io.vn # ✅ safe, client-side
DATABASE_URL=postgresql://... # ✅ server-only
OPENAI_API_KEY=sk-... # ✅ server-only
NEXT_PUBLIC_OPENAI_KEY=sk-... # ❌ EXPOSED TO BROWSER!
Using Doppler for Production Secrets
Doppler is a secrets manager with excellent Next.js / Vercel integration. It syncs secrets at build time and runtime without you storing anything in CI environment variables manually.
# Install Doppler CLI
curl -Ls https://cli.doppler.com/install.sh | sh
# Authenticate and set up project
doppler login
doppler setup --project nextfuture --config production
# Run Next.js with Doppler-injected secrets
doppler run -- next start
In CI/CD (e.g., GitHub Actions), use the Doppler service token:
# .github/workflows/deploy.yml
- name: Build with Doppler secrets
env:
DOPPLER_TOKEN: ${{ secrets.DOPPLER_SERVICE_TOKEN }}
run: doppler run -- next build
HashiCorp Vault for Enterprise
For enterprise environments, Vault provides dynamic secrets (database credentials that expire), secret leasing, and full audit trails:
// lib/vault.ts
import vault from "node-vault";
const client = vault({
apiVersion: "v1",
endpoint: process.env.VAULT_ADDR!,
token: process.env.VAULT_TOKEN!,
});
export async function getDatabaseUrl(): Promise {
const { data } = await client.read("database/creds/nextapp-role");
return `postgresql://${data.username}:${data.password}@db:5432/app`;
}
The key insight with Vault: credentials rotate automatically. If your DB credentials leak, they expire within minutes — the blast radius is minimal compared to static credentials.
7. Authentication and Authorization — The Invisible Boundary
App Router API routes have no built-in authentication — that's your responsibility. This maps to OWASP A01: Broken Access Control and is one of the most common vulnerabilities in Next.js apps.
The pattern that trips developers up: they protect the UI route but forget the API route behind it.
// app/api/admin/users/route.ts
import { auth } from "@/lib/auth"; // e.g., NextAuth, Clerk, Better Auth
export async function GET(req: NextRequest) {
const session = await auth();
// ❌ WRONG: only checking if logged in
if (!session) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
// ✅ CORRECT: check role/permission too
if (!session.user.roles.includes("admin")) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
const users = await prisma.user.findMany();
return NextResponse.json(users);
}
Build a reusable auth wrapper to avoid repetition:
// lib/api-auth.ts
import { auth } from "@/lib/auth";
import { NextRequest, NextResponse } from "next/server";
type Role = "user" | "admin" | "moderator";
export function withAuth(
handler: (req: NextRequest, session: Session) => Promise,
requiredRole?: Role
) {
return async (req: NextRequest): Promise => {
const session = await auth();
if (!session?.user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
if (requiredRole && !session.user.roles.includes(requiredRole)) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
return handler(req, session);
};
}
// Usage
export const GET = withAuth(async (req, session) => {
// session is guaranteed here
return NextResponse.json({ user: session.user });
}, "admin");
8. OWASP Top 10 Mapping for Next.js APIs
Let's close with a practical mapping of the OWASP Top 10 (2021) to Next.js-specific concerns:
- A01 — Broken Access Control: Always check auth in API routes and Server Actions. Don't rely on UI-level guards. Use RBAC wrappers like
withAuth(). - A02 — Cryptographic Failures: Use Doppler or Vault for secrets. Never commit
.env.local. Only use HTTPS. Hash passwords with bcrypt/argon2. - A03 — Injection: Use Prisma or parameterized queries. Sanitize HTML output. Validate all inputs with Zod.
- A04 — Insecure Design: Implement rate limiting from day one. Design for least privilege. Plan for abuse scenarios during architecture.
- A05 — Security Misconfiguration: Set security headers via
next.config.js. Configure CORS explicitly. Disable debug pages in production. - A06 — Vulnerable Components: Run
npm auditin CI. Use Dependabot or Snyk. Pin dependency versions in production. - A07 — Authentication Failures: Use a battle-tested auth library (NextAuth, Clerk). Implement MFA for sensitive roles. Invalidate sessions on password change.
- A08 — Data Integrity Failures: Validate all server action inputs. Use signed tokens (JWT with RS256) rather than symmetric HS256 for distributed systems.
- A09 — Logging Failures: Log auth failures, rate limit hits, and validation errors. Never log passwords or tokens. Use structured logging (Pino, Winston).
- A10 — SSRF: Validate and whitelist URLs before fetching them server-side. Use
URLparsing to prevent schema injection (file://,gopher://).
Putting It Together: A Secure API Route Skeleton
// app/api/posts/route.ts
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { ratelimit } from "@/lib/ratelimit";
import { withAuth } from "@/lib/api-auth";
import { sanitizeHtml } from "@/lib/sanitize";
import { prisma } from "@/lib/prisma";
const CreatePostSchema = z.object({
title: z.string().min(5).max(200).trim(),
content: z.string().min(50).max(50000),
categoryId: z.string().uuid(),
});
export const POST = withAuth(async (req: NextRequest, session) => {
// 1. Rate limiting
const ip = req.headers.get("x-forwarded-for") ?? "127.0.0.1";
const { success } = await ratelimit.limit(`post:${session.user.id}:${ip}`);
if (!success) {
return NextResponse.json({ error: "Rate limit exceeded" }, { status: 429 });
}
// 2. Input validation
let body: unknown;
try { body = await req.json(); }
catch { return NextResponse.json({ error: "Invalid JSON" }, { status: 400 }); }
const parsed = CreatePostSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json({ error: parsed.error.flatten() }, { status: 422 });
}
// 3. Sanitize HTML content
const cleanContent = sanitizeHtml(parsed.data.content);
// 4. Parameterized DB write (Prisma)
const post = await prisma.post.create({
data: {
title: parsed.data.title,
content: cleanContent,
categoryId: parsed.data.categoryId,
authorId: session.user.id,
},
});
return NextResponse.json({ post }, { status: 201 });
});
This single route handler demonstrates all the key layers: rate limiting by user+IP, Zod validation, HTML sanitization, and Prisma for safe DB writes — wrapped in an auth guard that checks both authentication and authorization.
Conclusion
Security in Next.js isn't a plugin you install at the end — it's a discipline you weave into every route, every action, and every data access. The good news: the ecosystem has matured enormously. Upstash makes rate limiting serverless-native. Prisma makes injection-safe queries the default. Zod makes input validation ergonomic. Doppler and Vault make secrets management a first-class workflow.
The threat model for a Next.js API is the same as any web API — but the blast radius when something goes wrong is amplified by the fact that your frontend and backend share a deployment. Get these fundamentals right, and you'll be ahead of 90% of production Next.js apps in the wild.
Next steps: audit your existing routes against this checklist, integrate npm audit into your CI pipeline, and consider a Content Security Policy for your rendered pages. Security is never done — but with the right primitives, it's sustainable.
Admin
Cal.com
Open source scheduling — self-host your booking system, replace Calendly. Free & privacy-first.
Comments (0)
Sign in to comment
No comments yet. Be the first to comment!