GetLaunchpad
Back to blog
6 min read

Next.js security checklist: protecting your SaaS in production

Auth checks, Zod validation, Upstash rate limiting, security headers, parameterized queries, and webhook signature verification — every layer you need to harden before shipping a Next.js SaaS to production.

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:

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:

Quick reference

Before you ship to production, verify each item:


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.

Share this article:Share on X

Ready to ship faster?

GetLaunchpad gives you everything covered in this guide — pre-configured, tested, and production-ready. Skip the setup and focus on your product.

Get the boilerplate →

More articles