SecureStartKit
SecurityFeaturesPricingDocsBlogChangelog
Sign inBuy Now
May 12, 2026·Security·SecureStartKit Team

The Secure SaaS Launch Checklist: 7 Non-Negotiables [2026]

Seven security checks every solo dev must verify before going live: auth, RLS, Zod, webhooks, headers, secrets, error handling. The pre-launch audit.

Summarize with AI

On this page

  • Table of contents
  • Why does a pre-launch security checklist beat a pre-launch panic?
  • Check 1: Auth and session validation that doesn't trust the client
  • Check 2: RLS enabled deny-all on every table
  • Check 3: Zod validation on every Server Action
  • Check 4: Stripe webhooks with signature verification and idempotency
  • Check 5: Security headers configured in next.config.ts
  • Check 6: No NEXT_PUBLIC prefix on any secret
  • Check 7: Error handling that doesn't leak internals
  • How do you run this checklist in 30 minutes before launch?

On this page

  • Table of contents
  • Why does a pre-launch security checklist beat a pre-launch panic?
  • Check 1: Auth and session validation that doesn't trust the client
  • Check 2: RLS enabled deny-all on every table
  • Check 3: Zod validation on every Server Action
  • Check 4: Stripe webhooks with signature verification and idempotency
  • Check 5: Security headers configured in next.config.ts
  • Check 6: No NEXT_PUBLIC prefix on any secret
  • Check 7: Error handling that doesn't leak internals
  • How do you run this checklist in 30 minutes before launch?

The seven non-negotiable security checks before launching a SaaS are: server-validated auth and sessions, RLS deny-all on every table, Zod validation on every Server Action, signed Stripe webhooks with idempotency, security headers configured in next.config.ts, no NEXT_PUBLIC prefix on any secret, and error handling that doesn't leak internals. Run them in order. They each take about four minutes. Skipping one is how you end up in the 11% of indie launches that ship with exposed credentials [1].

The pattern behind this list isn't novel. It's the breach floor. A January 2026 scan of 20,052 indie launch URLs found 2,217 domains exposing Supabase credentials in their frontend code, and 2,325 critical exposures involving either a leaked service_role key or tables with no RLS at all [1]. The 170+ Lovable apps disclosed in CVE-2025-48757 fit the same pattern: defaults that should have been verified, weren't [2]. This checklist is the verification step that closes those gaps before traffic arrives.

TL;DR:

  • The seven checks: auth/session validation, RLS deny-all, Zod on Server Actions, signed webhooks with idempotency, security headers, no NEXT_PUBLIC secrets, safe error handling.
  • The order matters: identity first (1, 2), input next (3), payments (4), perimeter (5, 6), runtime (7). Each layer assumes the previous one holds.
  • The time cost: about 30 minutes total, four minutes per check, with grep-friendly verification for most items.
  • The miss class: each item maps to a documented breach pattern from 2026. None of them are theoretical.
  • The companion tools: the SaaS Security Checklist tool walks the same checks interactively if you'd rather click through than scroll.

Table of contents

  • Why does a pre-launch security checklist beat a pre-launch panic?
  • Check 1: Auth and session validation that doesn't trust the client
  • Check 2: RLS enabled deny-all on every table
  • Check 3: Zod validation on every Server Action
  • Check 4: Stripe webhooks with signature verification and idempotency
  • Check 5: Security headers configured in next.config.ts
  • Check 6: No NEXT_PUBLIC prefix on any secret
  • Check 7: Error handling that doesn't leak internals
  • How do you run this checklist in 30 minutes before launch?

Why does a pre-launch security checklist beat a pre-launch panic?

A pre-launch checklist isn't an audit. It's a confirmation. You wrote the code knowing these checks existed, so on launch day you're not discovering problems, you're verifying defaults. That difference matters because the day before launch is the worst possible time to do a real audit. You're tired, you're rushing, and any vulnerability you find means choosing between launching with it or slipping the date.

The checklist below is the verification that catches the seven mistakes that have already hit real 2026 Next.js + Supabase apps. Every item maps to a documented breach class. None are speculative. The Authgear 2026 security guide names the same root causes: failing to validate inputs on the server, exposing service role keys, and trusting client-supplied identity [3]. The Cybersecurity Insight Report puts the rate of indie launches that ship with at least one of these problems at 11% [1]. If you've shipped what you think is a finished SaaS, this checklist is what tells you whether you actually shipped one.

This piece sits underneath the Ship a Secure SaaS in a Weekend pillar: the weekend playbook tells you how to build with these checks wired in, the checklist confirms they survived the build.

Check 1: Auth and session validation that doesn't trust the client

The check: every privileged operation reads identity from a server-validated session, never from a request body or query parameter. The cookie session is verified against Supabase's JWT signing key on every request that touches user data, using getClaims() or the equivalent server-side helper. The pattern const user = data.userId from form input never appears in any Server Action that does authorization.

Why this breach class: trusting userId from a form payload turns every Server Action into an Insecure Direct Object Reference. An attacker submits {userId: "victim-id", ...} and your action dutifully updates the victim's row. The Authgear analysis names this as one of the root causes behind Next.js incidents in 2026 [3]. The Makerkit Server Actions security guide phrases it as the cardinal rule: "Treat every 'use server' function as a public API endpoint" [4].

How to verify:

# 1. Search every Server Action for identity reads
grep -rn "user\.id\|userId" actions/

# 2. For each hit, the value must come from the session, not from input
# Pattern that PASSES: const user = await getUser(); ... .eq('id', user.id)
# Pattern that FAILS:  await updateRow({ id: input.userId, ... })

Every action must look like this, in this order:

'use server'

import { z } from 'zod'
import { getUser, createAdminClient } from '@/lib/supabase/server'

const schema = z.object({
  bio: z.string().max(500),
})

export async function updateBio(input: z.infer<typeof schema>) {
  // 1. Validate input (Check 3 covers this)
  const parsed = schema.safeParse(input)
  if (!parsed.success) return { error: 'Invalid input' }

  // 2. Read identity from the verified session, never from input
  const user = await getUser()
  if (!user) return { error: 'Unauthorized' }

  // 3. Scope the mutation to the session's user.id
  const admin = createAdminClient()
  await admin
    .from('profiles')
    .update(parsed.data)
    .eq('id', user.id) // <- session, not input
}

The full pattern, including the getClaims() vs getSession() distinction that matters for server-verified JWT trust, is in the Supabase Auth in Next.js App Router guide.

Check 2: RLS enabled deny-all on every table

The check: every table in your public schema has ENABLE ROW LEVEL SECURITY, with zero policies for anon and authenticated roles. The service_role bypasses RLS, so your Server Actions still work. The browser cannot read anything.

Why this breach class: "RLS enabled with permissive policies" looks identical to "RLS enabled with deny-all" from outside, but they behave opposite. Most breaches in the 2026 data come from drifted policies, not missing ones [1]. A column rename, a new join, or a forgotten auth.uid() filter is all it takes. Deny-all sidesteps the entire category by removing the surface where drift can happen.

How to verify:

-- Run in the Supabase SQL editor before promoting to prod
SELECT schemaname, tablename, rowsecurity
FROM pg_tables
WHERE schemaname = 'public';

Every row should show rowsecurity = true. If any table shows false, that table is fully public to anyone with your anon key. Then list policies:

SELECT schemaname, tablename, policyname, roles
FROM pg_policies
WHERE schemaname = 'public';

Zero rows is the deny-all posture. If you have policies, each one is a place where drift can introduce a leak; review each manually against the RLS patterns that hold up under attack. For policies you do need (a public read-only feed, for example), the RLS Policy Generator scaffolds the common safe patterns. The Supabase docs are explicit that RLS is one defensive layer, not the only one [6], which is exactly why deny-all plus backend-only access is the safer architectural pair.

Check 3: Zod validation on every Server Action

The check: every file under actions/ defines a Zod schema and calls safeParse(input) as the first line of the action body. No untyped destructuring of input. No "we'll validate it in the database constraint." Validation happens at the entry boundary, before any database call.

Why this breach class: Server Actions are public HTTP endpoints. Anyone who can read your client bundle can call them with any payload they want. The Next.js team has been clear that this is the design, not a bug. The Authgear guide ranks "failing to validate inputs on the server" as one of the top three root causes behind 2026 Next.js incidents [3]. Skip Zod, and you've shipped an endpoint that trusts whatever JSON the attacker sends.

How to verify:

# Every action file should have a safeParse call near the top
grep -rln "safeParse\|\.parse(" actions/

# Compare against the file count
ls actions/*.ts | wc -l

The two counts should match. Any action file without a safeParse is a file accepting unvalidated input. For each schema, the rules:

  • Strings have .min() and .max() bounds. An unbounded string is a 10MB POST waiting to happen.
  • Numbers have ranges. z.number().int().positive().max(1000) beats z.number().
  • Enums are z.enum([...]), not free-form strings checked at the DB.
  • Objects are explicit about every field. No z.object({}).passthrough().

The canonical pattern, including how to share schemas between client and server forms, is in the Server Actions and Zod guide. If you're converting an existing TypeScript type to a Zod schema, the JSON-to-Zod converter handles the scaffolding.

Check 4: Stripe webhooks with signature verification and idempotency

The check: your /api/webhooks/stripe route reads the raw request body, calls stripe.webhooks.constructEvent() with your endpoint secret, and rejects anything that fails signature verification. Then it dedupes by event.id against a stripe_events table with a unique constraint before processing.

Why this breach class: the webhook endpoint is publicly reachable. Anyone with curl can send a checkout.session.completed event with whatever metadata they want. If your handler marks orders as paid based on the event body without verifying the signature, the attacker just bought your product for free. Stripe's docs are explicit: "Use the official libraries from Stripe to verify events" [5]. The second half, idempotency, exists because Stripe retries failed deliveries. Without dedup, a flaky network turns one purchase into five paid orders.

How to verify:

# Find the webhook handler
grep -rn "stripe\.webhooks\.constructEvent" app/api/

# Should return exactly one match per webhook route

The handler should look like this:

import { headers } from 'next/headers'
import { stripe } from '@/lib/stripe/server'
import { createAdminClient } from '@/lib/supabase/server'

export async function POST(req: Request) {
  const rawBody = await req.text()
  const signature = (await headers()).get('stripe-signature')

  if (!signature) {
    return new Response('Missing signature', { status: 400 })
  }

  let event
  try {
    event = stripe.webhooks.constructEvent(
      rawBody,
      signature,
      process.env.STRIPE_WEBHOOK_SECRET!
    )
  } catch (err) {
    return new Response('Invalid signature', { status: 400 })
  }

  // Idempotency: insert event ID, unique constraint catches duplicates
  const admin = createAdminClient()
  const { error: insertError } = await admin
    .from('stripe_events')
    .insert({ id: event.id, type: event.type })

  if (insertError?.code === '23505') {
    return new Response('OK', { status: 200 }) // already processed
  }

  // Now process the event safely...
}

Three places this typically goes wrong: parsing JSON before verification (which breaks the HMAC), using the wrong secret (test vs live), and missing the idempotency dedup. The Stripe Webhook Verifier tool tests your signature setup against a known event before you flip the production key. The full implementation, including the Server Actions integration, is in the Stripe payments guide.

Check 5: Security headers configured in next.config.ts

The check: next.config.ts exports a headers() function that sets at minimum Content-Security-Policy, Strict-Transport-Security, X-Frame-Options, X-Content-Type-Options, Referrer-Policy, and Permissions-Policy on every response. CSP uses a nonce for inline scripts, not unsafe-inline.

Why this breach class: missing headers don't cause breaches on their own; they remove the cheap defenses that would have blunted other mistakes. Without HSTS, a stolen session cookie travels over plain HTTP the next time the user types your domain. Without CSP, an XSS that should have been contained executes everywhere. Without X-Frame-Options, a clickjacking page can wrap your auth flow in an invisible iframe. The Next.js docs cover CSP setup, including the nonce pattern that lets you avoid unsafe-inline [7].

How to verify:

# Check that headers() exists in next.config.ts
grep -n "headers()" next.config.ts

# After deploy, verify the live response
curl -I https://your-domain.com | grep -iE "content-security|strict-transport|x-frame"

Three headers must appear in the response. If curl -I doesn't show them, the next.config.ts export isn't wired correctly, or you forgot to redeploy after the change. The Next.js Security Headers tool generates a copy-paste config for the six headers above, with sensible defaults you can tighten as you learn what your CSP needs to allow. The Next.js Security Hardening Checklist covers the surrounding 11 steps that pair with headers.

Check 6: No NEXT_PUBLIC prefix on any secret

The check: the NEXT_PUBLIC_ prefix exists on exactly the variables that are safe in a browser bundle: the Supabase project URL and the anon key. Every other secret, including SUPABASE_SERVICE_ROLE_KEY, STRIPE_SECRET_KEY, STRIPE_WEBHOOK_SECRET, RESEND_API_KEY, and any third-party API key, has no prefix.

Why this breach class: anything prefixed with NEXT_PUBLIC_ ships in the JavaScript bundle that every visitor downloads. A leaked service_role key bypasses RLS entirely; it's full database admin access for the price of viewing source. The Hacktron AI disclosure of CVE-2025-48757 ran exactly this attack chain on 170+ Lovable apps: read the bundle, extract credentials, query the data [2]. The January 2026 scan found 2,325 apps with the equivalent problem still in production [1]. The vibe-coded breach pattern starts here. The exposed API keys guide walks through how AI coding assistants reintroduce this exact mistake when you ask for "quick" client-side queries.

How to verify:

# Any prefix on a secret-shaped name is a critical fail
grep -rn "NEXT_PUBLIC_.*SECRET\|NEXT_PUBLIC_.*SERVICE_ROLE\|NEXT_PUBLIC_.*PRIVATE" .

# Also check the .env files specifically
grep -E "^NEXT_PUBLIC_" .env.local .env.example

The first command should return zero matches. The second should only show the two Supabase publishable values. If you see anything resembling NEXT_PUBLIC_STRIPE_SECRET_KEY, stop the launch and rotate the key first; you cannot un-leak a credential that's been live in production [2]. The API Key Generator tool helps when you need to issue your own internal keys without picking weak patterns.

Check 7: Error handling that doesn't leak internals

The check: Server Actions and API routes catch errors, log the full detail server-side (Sentry, console, structured logger), and return generic messages to the client. The literal string of a Supabase error, a Stripe error, or a stack trace never reaches the user.

Why this breach class: error messages are a slow leak. A 500 response that says "duplicate key value violates unique constraint users_email_key" tells an attacker your column names and that a given email is already registered, which enables enumeration. A Stripe error that leaks the customer ID gives them a known good ID to use elsewhere. Generic "Something went wrong" responses paired with detailed server logs preserve every debugging signal you need without giving attackers reconnaissance.

How to verify: scan for raw error returns:

# Patterns that leak internals
grep -rn "error\.message\|err\.message" actions/ app/api/
grep -rn "JSON\.stringify(error)" actions/ app/api/

For each hit, the safe pattern is:

try {
  // ... operation
} catch (err) {
  console.error('updateProfile failed', err) // server-side detail
  Sentry.captureException(err)                // server-side aggregation
  return { error: 'Something went wrong. Please try again.' } // client-safe
}

The full pattern, including the error.tsx boundary for unhandled errors at the page level and Sentry configuration that filters PII before it leaves your servers, is in the Next.js error handling and Sentry setup guide.

How do you run this checklist in 30 minutes before launch?

Run the checks in order, top to bottom. Each one takes between two and five minutes if your stack is Next.js plus Supabase plus Stripe. The order isn't arbitrary: identity first (1 and 2), input next (3), payments (4), perimeter (5 and 6), runtime (7). Each layer assumes the one before it holds.

A compressed run:

#CheckTimeVerification
1Server-validated auth on every action4 mingrep -rn "user\.id|userId" actions/
2RLS deny-all on every table3 minpg_tables query in Supabase SQL editor
3Zod on every Server Action4 mingrep -rln "safeParse" actions/ matches file count
4Stripe webhook verification + idempotency6 mingrep -rn "constructEvent" app/api/ plus test event
5Security headers in next.config.ts3 mincurl -I against staging URL
6No NEXT_PUBLIC_ on any secret2 mingrep on env files
7Error handling without leaks4 mingrep -rn "error\.message" actions/ app/api/

If anything fails, fix it before you flip the production DNS. The SaaS Security Checklist tool runs the same checks interactively against your codebase if you'd rather click through, and the security architecture deep dive covers the architectural decisions behind each item if you want the "why" before the "how."

This is the foundation SecureStartKit ships with by default: every Server Action validates with Zod first, every table has RLS deny-all in supabase/schema.sql, the Stripe webhook handler uses constructEvent with idempotency dedup, and the headers are pre-configured. The site you're reading runs on it. The checklist is what tells you your version of the stack still does too.

Frequently Asked Questions

What are the 7 non-negotiable security checks before launching a SaaS?
Server-validated auth and sessions, RLS enabled deny-all on every table, Zod validation on every Server Action, signed Stripe webhooks with idempotency dedup, security headers (CSP, HSTS, X-Frame-Options) in next.config.ts, no NEXT_PUBLIC prefix on any secret, and error handling that returns generic messages to the client while logging details server-side.
How long does the pre-launch security checklist take to run?
Around 30 minutes if your stack is Next.js plus Supabase plus Stripe and you can grep the codebase. Each check resolves to one or two specific commands or a single file inspection. The longest item is verifying the Stripe webhook end-to-end, which requires a test event from the Stripe CLI.
What's the difference between a security audit and a launch checklist?
An audit is broad and exploratory: you look for unknown vulnerabilities across the whole codebase. A launch checklist is narrow and confirmatory: you verify seven specific controls are in place. The checklist catches the breach classes that have already hit real Next.js plus Supabase apps in 2026. The audit is what you do quarterly after launch.
Can I skip the checklist if I'm using a security-focused template?
No. A template gives you secure defaults, but every Server Action you write, every table you add, and every environment variable you configure can break the defaults. The checklist confirms the defaults are still intact at the moment you deploy. Five minutes of verification beats five days of incident response.

Built for developers who care about security

SecureStartKit ships with these patterns out of the box.

Backend-only data access, Zod validation on every input, RLS enabled, Stripe webhooks verified. One purchase, lifetime updates.

View PricingSee the template in action

References

  1. Vibe Coding Cybersecurity Insight Report (January 2026)— supaexplorer.com
  2. SupaPwn: Hacking Our Way into Lovable's Office and Helping Secure Supabase— hacktron.ai
  3. Next.js Security Best Practices: Complete 2026 Guide— authgear.com
  4. Next.js Server Actions Security: 5 Vulnerabilities You Must Fix— makerkit.dev
  5. Verify webhook signatures— docs.stripe.com
  6. Row Level Security— supabase.com
  7. Content Security Policy— nextjs.org

Related Posts

May 11, 2026·Security

The Security Architecture Most SaaS Templates Skip [2026]

Five architectural patterns most Next.js SaaS templates skip: backend-only access, Zod everywhere, RLS deny-all, signed webhooks, server-only imports.

May 9, 2026·Security

Backend-Only Data Access in Next.js + Supabase [2026]

The architectural pattern that prevents Supabase data leaks. Server Actions, admin client, no NEXT_PUBLIC key for queries, ever.

Mar 3, 2026·Security

Vibe Coding Security Checklist: Audit AI Apps [2026]

Vibe coding tools like Cursor and v0 build apps fast, but they often ship vulnerabilities. Here is the technical audit checklist for Next.js and Supabase apps.