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_PUBLICsecrets, 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. The OAuth, magic-link, and MFA implementation pillar covers the PKCE callback, redirect URL allowlist, and AAL2 step-up that this check assumes are wired correctly. For the MFA-recovery branch (lost device, support-side reset, backup factor enrollment), the audited service_role reset pattern covers the 5 lost-device implementation traps and the auth.admin.mfa.deleteFactor() audit-row-before-destructive-call discipline. For the password-recovery branch of the same auth surface, the Supabase password reset failure-mode catalog walks the five implementation gaps that turn a working reset flow into an account-takeover vector.
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)beatsz.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 full catalogue of Stripe webhook verification failure modes covers the two other causes plus the v0-scheme trap. 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 7-header next.config.ts setup pillar explains what each header blocks, where to put each one (next.config.ts vs middleware), and the COOP/Stripe popup trap that breaks Checkout when you over-tighten. 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. For the structural reasons this check exists, see the Next.js environment variable leak prevention guide, which catalogues all six leak modes the launch checklist defends against.
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:
| # | Check | Time | Verification |
|---|---|---|---|
| 1 | Server-validated auth on every action | 4 min | grep -rn "user\.id|userId" actions/ |
| 2 | RLS deny-all on every table | 3 min | pg_tables query in Supabase SQL editor |
| 3 | Zod on every Server Action | 4 min | grep -rln "safeParse" actions/ matches file count |
| 4 | Stripe webhook verification + idempotency | 6 min | grep -rn "constructEvent" app/api/ plus test event |
| 5 | Security headers in next.config.ts | 3 min | curl -I against staging URL |
| 6 | No NEXT_PUBLIC_ on any secret | 2 min | grep on env files |
| 7 | Error handling without leaks | 4 min | grep -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, the security architecture deep dive covers the architectural decisions behind each item if you want the "why" before the "how," and the OWASP Top 10 for Next.js + Supabase maps all 7 checks above to the OWASP category each one defends against.
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.
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.
References
- Vibe Coding Cybersecurity Insight Report (January 2026)— supaexplorer.com
- SupaPwn: Hacking Our Way into Lovable's Office and Helping Secure Supabase— hacktron.ai
- Next.js Security Best Practices: Complete 2026 Guide— authgear.com
- Next.js Server Actions Security: 5 Vulnerabilities You Must Fix— makerkit.dev
- Verify webhook signatures— docs.stripe.com
- Row Level Security— supabase.com
- Content Security Policy— nextjs.org
Related Posts
Rotate Leaked API Keys Without Downtime [2026]
Rotating a leaked API key the wrong way logs out every user or breaks your webhooks. The zero-downtime runbook for Supabase, Stripe, and Resend keys.
Pre-Launch Security Audit: 12 Checks That Matter Most [2026]
Pre-launch security audit for Next.js + Supabase: 12 highest-impact checks of 30, in audit order, with triage rules. Run weeks before launch.
Secure Image Uploads in Next.js: 5 Edge Cases [2026]
Magic-byte checks pass polyglots and miss decompression bombs. Re-encode every image upload through Sharp to strip EXIF and neutralize payloads.