Most Next.js security vulnerabilities aren't exotic — they're the same handful of mistakes repeated across codebases: missing auth checks, unvalidated inputs, leaked secrets, and skipped webhook verification. This checklist covers every layer you need to harden before shipping a SaaS to production.
1. Always check auth first in every API route
The most common mistake is assuming a route is “protected” because it lives behind a dashboard UI. At the HTTP layer, every route handler is public until you explicitly gate it. Make auth the first thing in every route handler — before any database reads, business logic, or expensive operations:
// app/api/projects/route.ts
import { auth } from "@clerk/nextjs/server";
export async function GET() {
const { userId } = await auth();
if (!userId) return new Response("Unauthorized", { status: 401 });
// safe to query the database now
}
Never trust a user ID sent in the request body or query string. An attacker can send any value there. Always derive the user identity from the verified session — in Clerk's case, that's auth().userId, which comes from a signed JWT.
2. Validate every API input with Zod
TypeScript types disappear at runtime. A malicious request can send any shape of JSON to your API routes, and without validation you'll be passing untrusted data straight into your database. Zod lets you declare the expected shape once and throws if the input doesn't match:
import { z } from "zod";
const CreateProjectSchema = z.object({
name: z.string().min(1).max(100),
description: z.string().max(500).optional(),
});
export async function POST(req: Request) {
const { userId } = await auth();
if (!userId) return new Response("Unauthorized", { status: 401 });
const body = await req.json();
const parsed = CreateProjectSchema.safeParse(body);
if (!parsed.success) {
return Response.json({ error: parsed.error.flatten() }, { status: 400 });
}
// parsed.data is now safe and typed
}
Use safeParse rather than parse so you can return a clean 400 error instead of letting an exception bubble up into a 500.
3. Rate-limit all mutation endpoints
Without rate limiting, a single bad actor can hammer your Stripe checkout endpoint and create thousands of orphaned sessions, or brute-force any endpoint that accepts credentials. Upstash Redis provides a serverless sliding-window rate limiter that works in Next.js edge and Node runtimes:
import { Ratelimit } from "@upstash/ratelimit";
import { Redis } from "@upstash/redis";
const ratelimit = new Ratelimit({
redis: Redis.fromEnv(),
limiter: Ratelimit.slidingWindow(10, "60 s"),
});
export async function POST(req: Request) {
const ip = req.headers.get("x-forwarded-for") ?? "anonymous";
const { success } = await ratelimit.limit(ip);
if (!success) return new Response("Too Many Requests", { status: 429 });
// proceed
}
Apply rate limiting to checkout, password reset, contact forms, and any route that triggers external API calls or sends email.
4. Never expose service role keys in client components
Next.js prefixes any environment variable with NEXT_PUBLIC_ to expose it to the browser bundle. Everything else stays server-side only. Rules to follow:
SUPABASE_SERVICE_ROLE_KEY — server only, never NEXT_PUBLIC_. This key bypasses all Row Level Security policies.STRIPE_SECRET_KEY — server only. The publishable key is safe to expose.CLERK_SECRET_KEY — server only. The publishable key gets a NEXT_PUBLIC_ prefix by design.OPENAI_API_KEY, RESEND_API_KEY, database credentials — all server only.
If you're ever unsure, search your codebase for the variable name in client component files (any file without “use server” or that uses hooks). If it appears there, it will leak.
5. Set security headers
HTTP security headers are a low-effort, high-impact layer of defense. Add them to next.config.ts:
const securityHeaders = [
{ key: "X-Frame-Options", value: "SAMEORIGIN" },
{ key: "X-Content-Type-Options", value: "nosniff" },
{ key: "Referrer-Policy", value: "strict-origin-when-cross-origin" },
{
key: "Strict-Transport-Security",
value: "max-age=63072000; includeSubDomains; preload",
},
{
key: "Content-Security-Policy",
value: [
"default-src 'self'",
"script-src 'self' 'unsafe-inline' 'unsafe-eval' https://clerk.com",
"style-src 'self' 'unsafe-inline'",
"img-src 'self' data: https:",
"connect-src 'self' https://api.clerk.dev wss://ws.clerk.dev",
].join("; "),
},
];
export default {
headers: async () => [
{ source: "/(.*)", headers: securityHeaders },
],
};
CSP is the most complex header — start with a loose policy and tighten it over time using the Content-Security-Policy-Report-Only header to collect violations before enforcing them.
6. Never string-interpolate user input into SQL
Supabase's JavaScript client uses parameterized queries by default — as long as you use the query builder methods, your inputs are safe. The danger arises if you drop into raw SQL via rpc() or supabase.rpc() with string interpolation:
// DANGEROUS — never do this
const { data } = await supabase.rpc("search", {
query: `SELECT * FROM posts WHERE title = '${userInput}'`,
});
// SAFE — parameterized via the query builder
const { data } = await supabase
.from("posts")
.select("*")
.eq("title", userInput);
If you need custom SQL functions, write them in Supabase's SQL editor and call them via rpc() with a parameters object — never build the SQL string on the client side.
7. Verify all webhook signatures
Webhook endpoints are unauthenticated HTTP routes — anyone can POST to them. Without signature verification, an attacker could fake a “subscription activated” event and unlock paid features for free. Both Stripe and Clerk sign their webhook payloads:
// Stripe
import Stripe from "stripe";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
export async function POST(req: Request) {
const body = await req.text();
const sig = req.headers.get("stripe-signature")!;
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(
body,
sig,
process.env.STRIPE_WEBHOOK_SECRET!
);
} catch {
return new Response("Invalid signature", { status: 400 });
}
// safe to process event
}
The key detail: read the raw body with req.text() before parsing — Stripe verifies the signature against the raw bytes. Parsing JSON first changes the payload and breaks verification.
8. Audit dependencies regularly
Third-party packages are the most common vector for supply chain attacks. Build these habits into your workflow:
- Run
npm audit before every deployment. Fix critical and high-severity issues immediately. - Pin major versions in
package.json to avoid surprise breaking changes from automatic updates. - Review the changelog before upgrading any security-critical package (Clerk, Stripe, Supabase client).
- Enable Dependabot or Renovate on GitHub to receive automated pull requests for outdated dependencies.
- Minimize your dependency surface area — if you can implement something in 10 lines, you don't need a package.
Quick reference
Before you ship to production, verify each item:
- Every API route checks auth before doing anything else
- Every route that accepts a body validates it with Zod
- All mutation endpoints are rate-limited with Upstash
- No service role keys or secret keys have a
NEXT_PUBLIC_ prefix - Security headers are configured in
next.config.ts - All database queries use parameterized inputs, never string interpolation
- Stripe and Clerk webhook signatures are verified before processing events
npm audit returns zero critical vulnerabilities
Every item on this checklist is already implemented in GetLaunchpad, a Next.js 16 SaaS boilerplate. Auth checks, Zod validation, Upstash rate limiting, Stripe webhook verification, and security headers are all pre-wired so you can focus on your product instead of the security scaffolding.