
A SaaS security checklist before launch is worth more than a stack of compliance badges you don't have yet. Here's the uncomfortable truth: most launch checklists tell you what to secure and never show you how. This one ships the code. We build on Next.js and Supabase, we've watched a single missing tenant_id filter let a test account read another customer's data, and IBM's 2024 Cost of a Data Breach report put the global average at $4.88M. You don't need SOC 2 to go live. You need the app-layer baseline below, grouped, runnable, and mapped to OWASP and NIST.
Key Takeaways
- You don't need SOC 2 or a pentest to launch. You need the app-layer baseline below.
- The most dangerous launch bug is cross-tenant data leakage from a missing
tenant_idcheck. - Never roll your own auth. Use Auth.js, Clerk, or Supabase Auth.
- Audit
NEXT_PUBLIC_prefixes before you deploy. That's the fastest way to leak a secret.
This baseline maps to OWASP ASVS 5.0 and the NIST Secure Software Development Framework (SSDF), the two references Google trusts most for this topic and, notably, the two neither of the top-ranking guides bothers to cite.
Your Pre-Launch Security Checklist (Quick Version)
These are the minimum security requirements before you ship, grouped into six categories. Forty items. Run them top to bottom, hand the code sections to your dev, and treat anything marked P0 in the priority table below as a launch blocker.
Secrets & Config
.envis in.gitignorefrom commit one and was never committed.- Every
NEXT_PUBLIC_andVITE_prefix is audited; nothing secret ships to the browser. - Server secrets live in a manager (platform env vars, AWS Secrets Manager, Vault), not the repo.
- Any key that ever touched git history is rotated before launch.
- No secrets appear in logs, error payloads, or the client bundle.
- You grepped the built bundle for live keys (
grep -r "sk_live" .next/).
Auth & Access
- Auth is built on a library (Auth.js, Clerk, or Supabase Auth), not hand-rolled.
- MFA is available on accounts.
- Session cookies set
Secure,HttpOnly, andSameSite. - No JWTs or session tokens are stored in
localStorage. - RBAC and least-privilege roles are enforced server-side, not just hidden in the UI.
- Passwords are hashed with Argon2 or bcrypt (only if you self-manage auth).
- Password-reset and email-verification flows are tested against abuse.
Data & Tenancy
- Every query carries a
tenant_idfilter. - Tenant scoping is enforced at the ORM or repository layer, not remembered per query.
- Row-level security is enabled and its failure modes are understood.
- Every object-ID endpoint runs an ownership check (this kills IDOR).
tenant_idis included in cache keys and object-storage paths.- Data is encrypted at rest and in transit.
- Payment and webhook signatures (Stripe, etc.) are verified server-side.
Dependencies & Supply Chain
npm auditorpnpm auditis clean of high and critical (or explicitly triaged).- Dependabot or Renovate is enabled.
- Snyk or Socket runs deeper SCA plus malware and license checks.
- The lockfile is committed.
- No abandoned or unmaintained packages sit in the critical path.
- Container images are scanned if you ship Docker.
Network & Transport
- HTTPS is enforced everywhere, with HSTS preload.
- A Content-Security-Policy is set (report-only first, then enforce).
X-Content-Type-Options: nosniffandX-Frame-Options/frame-ancestorsare set.Referrer-PolicyandPermissions-Policyare set.- CORS uses an allow-list, never
*with credentials. - Rate limiting protects auth and expensive endpoints.
- Every endpoint validates input with a schema (Zod or similar).
Monitoring & Response
- Centralized audit logs record who accessed what, and when.
- Error handling never leaks stack traces to users.
- Alerts fire on auth anomalies (failed-login spikes, impossible travel).
- Automated backups run, and you have tested a restore.
- An incident-response contact and one-page runbook exist.
- Uptime and error monitoring (Sentry or equivalent) is live.
- You know the trigger for bringing in a pentest.
Fix-First Priority
Not every item blocks launch. This triage table sorts the baseline by damage-if-skipped so a founder knows what's non-negotiable. P0 = fix before launch, P1 = fix week one, P2 = fix within the quarter.
| Check | Category | If you skip it | Fix effort | Ship-blocker? |
|---|---|---|---|---|
| Cross-tenant isolation on every query | Data & Tenancy | One customer reads another's data | Med | P0: block launch |
| Secrets out of the client bundle | Secrets & Config | Public API keys, account takeover | Low | P0: block launch |
| Ownership check on object-ID endpoints | Data & Tenancy | IDOR: incrementing an id leaks records | Low | P0: block launch |
| Auth on a library, not hand-rolled | Auth & Access | Shipped auth bugs, broken sessions | Med | P0: block launch |
| HTTPS and HSTS everywhere | Network & Transport | Token theft over the wire | Low | P0: block launch |
| npm audit clean of high/critical | Dependencies | Known CVE in a transitive dep | Low | P1: week one |
| Rate limiting on auth endpoints | Network & Transport | Credential stuffing, brute force | Low | P1: week one |
| Security headers (CSP, HSTS, nosniff) | Network & Transport | XSS, clickjacking, MIME attacks | Low | P1: week one |
| Centralized audit logs | Monitoring | You can't see or prove a breach | Med | P1: week one |
| Tested backup restore | Monitoring | A backup that doesn't restore is nothing | Med | P1: week one |
| MFA available on accounts | Auth & Access | Easier account takeover | Low | P2: this quarter |
| Full CSP enforced past report-only | Network & Transport | Residual XSS surface | Med | P2: this quarter |
Secrets & Config: Are Any Keys Leaking Into Your Client Bundle?
Secrets hygiene at launch means no credential ever reaches the browser. The NEXT_PUBLIC_ prefix in Next.js (and VITE_ in Vite) ships a value to every visitor, so one wrong prefix leaks a key. Keep .env out of git, put server secrets in a manager, and grep your build output before you deploy.
Here's the gotcha we see most: NEXT_PUBLIC_ doesn't mean "public info." It means "I am literally shipping this to every visitor's browser." Prefix a Stripe secret or a service-role key that way and it's live in the bundle for anyone who opens DevTools.
# .env.local (the mistake)
NEXT_PUBLIC_STRIPE_SECRET=sk_live_51H... # BAD: ships to every browser
STRIPE_SECRET_KEY=sk_live_51H... # OK: server-only
# Public (safe to expose) vs server-only
NEXT_PUBLIC_SUPABASE_ANON_KEY=eyJ... # anon key is meant to be public
SUPABASE_SERVICE_ROLE_KEY=eyJ... # never NEXT_PUBLIC_ this
# Catch a leaked key before you deploy
grep -r "sk_live" .next/ # any hit means a secret is in your client bundleThe rest of the secrets baseline is boring and non-negotiable: .env in .gitignore from the first commit, server secrets in a manager rather than the repo, and a rotation of any key that ever touched git history (deleting a commit does not un-leak it). A leaked deploy token is exactly how breaches like the Vercel incident start, so treat every token as if it's already on someone's watchlist.
Auth & Access: Should You Build Auth or Use a Library?
Should you build auth or use a library? Almost always use a library. Auth.js, Clerk, and Supabase Auth have absorbed years of edge cases you'll otherwise rediscover in production: session fixation, token revocation, reset-flow abuse. Rolling your own is defensible only with a security engineer and a reason no provider fits, which is rare.
Rolling your own auth is the most expensive way to save $25 a month. Here's how the honest options compare.
| Option | Best when | MFA built-in | Default session | Gotcha |
|---|---|---|---|---|
| Auth.js (NextAuth) | You want free, self-hosted, full control | Via providers/add-ons | JWT or database | You own every security edge case |
| Clerk | You want MFA, UI, and orgs out of the box | Yes | Managed | Paid tiers scale with active users |
| Supabase Auth | You already run Supabase and Postgres RLS | Yes | JWT | RLS policy quality is on you |
| Roll your own | You have a security engineer and no provider fits | You build it | You build it | Most auth bugs start right here |
Two pitfalls sink teams that pick a library but skip the config. First, JWT revocation is genuinely hard, so a stolen token stays valid until it expires; keep token lifetimes short and prefer server-side sessions for anything sensitive. Second, tokens in localStorage are stealable by any XSS payload, so store sessions in httpOnly cookies with Secure and SameSite. Enforce RBAC on the server, not by hiding buttons in the UI.
If your SaaS ships an AI or LLM feature, treat model input as an untrusted auth boundary too. See our guide to preventing prompt injection, because a jailbroken assistant with tool access is an access-control problem wearing a chat window.
Data & Tenancy: How Do You Stop One Tenant From Reading Another's Data?
Tenant isolation means every query, cache key, and storage path is scoped to the current tenant. A missing tenant_id filter lets one customer read another's data, the most dangerous launch bug there is. Row-level security helps, but it's a seatbelt, not a force field, so add ownership checks on every object-ID endpoint too.
This is the section no competitor covers as code, and it's the reason cross-tenant leaks slip into production. The fix starts with never trusting an ID on its own. Scope every read to the caller's tenant, and enforce it at the data layer so nobody has to remember it per query.
// BAD: no tenant scope. Any valid id returns any tenant's row.
const order = await db.order.findFirst({ where: { id } });
// GOOD: scoped to the caller's tenant on every read.
const order = await db.order.findFirst({
where: { id, tenantId: session.tenantId },
});The related failure is IDOR (insecure direct object reference), which the OWASP API Security Top 10 files under API1: Broken Object Level Authorization. A test account increments an ID in the URL and reads a record it should never see. PortSwigger's Web Security Academy has a full walkthrough of how attackers find these. The fix is one ownership check.
// GET /api/invoices/1234, a test account increments the id
// and reads another tenant's invoice. Classic BOLA.
const invoice = await db.invoice.findUnique({ where: { id } });
// Fix: verify ownership before you return anything.
if (invoice.tenantId !== session.tenantId) {
return res.status(403).json({ error: "Forbidden" });
}Here's the framing that keeps you honest: every IDOR is a tenant-isolation failure, but not every tenant-isolation failure is an IDOR. Postgres row-level security catches many of them at the database, but it has silent-failure modes worth knowing before you rely on it.
-- Postgres RLS: a seatbelt, not a force field.
alter table orders enable row level security;
create policy tenant_isolation on orders
using (tenant_id = current_setting('app.tenant_id')::uuid);
-- Silent-failure trap: forget to SET app.tenant_id on a pooled
-- connection and the policy reads the PREVIOUS request's tenant.Connection-pool contamination, async-context leaks, and shared-cache poisoning all defeat RLS quietly, which is why the OWASP Multi-Tenant Security Cheat Sheet tells you to prefix cache keys and storage paths with the tenant too. The access-control chapter of OWASP ASVS 5.0 and the Supabase RLS docs are the two references worth reading in full here.
Dependencies & Supply Chain: What's Hiding in Your node_modules?
Your app is only as safe as its weakest transitive dependency. Run npm audit or pnpm audit in CI and fail the build on high or critical findings before you ever launch. Add Dependabot or Renovate for automatic updates, and Snyk or Socket for deeper malware and license checks.
The trap is running the audit once by hand, seeing green, and never running it again. Wire it into CI so a new CVE in a package you haven't touched still blocks the merge.
# .github/workflows/ci.yml: block the merge on high/critical
- name: Audit dependencies
run: npm audit --audit-level=high # non-zero exit fails the jobThe npm audit docs cover the severity levels and the --production flag if you want to ignore dev-only findings. Automated scanning is table stakes, though; for deeper static analysis that catches code smells and injection paths a dependency scanner misses, see our SonarQube review. Commit your lockfile, drop packages that haven't shipped a release in years, and scan your container image if you deploy Docker.
Network & Transport: Which Security Headers Does a SaaS Actually Need?
Which security headers does a SaaS need? HTTPS plus HSTS and a short header set close the easiest-to-exploit gaps. Add a Content-Security-Policy, a CORS allow-list instead of a wildcard, and rate limits on auth and expensive endpoints. Validate every input with a schema like Zod so bad payloads never reach your logic.
You don't need every header ever invented. You need this short list, and the MDN security-headers reference explains each in depth.
| Header | Recommended value | What it stops |
|---|---|---|
| Strict-Transport-Security | max-age=63072000; includeSubDomains; preload | Protocol downgrade, SSL-strip attacks |
| Content-Security-Policy | default-src 'self'; start report-only | XSS, injected scripts, data exfiltration |
| X-Content-Type-Options | nosniff | MIME-sniffing that turns an upload into a script |
| X-Frame-Options / frame-ancestors | DENY (or frame-ancestors 'none') | Clickjacking via hidden iframes |
| Referrer-Policy | strict-origin-when-cross-origin | Leaking full URLs (and tokens inside them) |
| Permissions-Policy | camera=(), microphone=(), geolocation=() | Rogue scripts touching device APIs |
Set the headers once, at the edge, and add a rate limiter so a script can't brute-force your login route all night.
// next.config.js: security headers on every response
const securityHeaders = [
{ key: "Strict-Transport-Security", value: "max-age=63072000; includeSubDomains; preload" },
{ key: "X-Content-Type-Options", value: "nosniff" },
{ key: "X-Frame-Options", value: "DENY" },
{ key: "Referrer-Policy", value: "strict-origin-when-cross-origin" },
];
// Rate-limit auth routes (Upstash example)
const { success } = await ratelimit.limit(ip);
if (!success) return new Response("Too many requests", { status: 429 });Start your CSP in report-only so you don't break your own app, watch the violation reports for a few days, then flip it to enforce. Keep CORS to a named allow-list, and never pair * with credentials.
Monitoring & Response: How Will You Know If You've Been Breached?
You can't respond to what you can't see. Before launch, wire up centralized audit logs, alerts on auth anomalies like failed-login spikes, automated backups with a tested restore, and a one-page incident runbook. An untested backup is a hope, not a backup, and the time to write the runbook is now, not mid-incident.
IBM's data puts the average time to identify and contain a breach at 258 days, and you can't shrink that number if your logs don't record who touched what. Centralize them, alert on the anomalies that matter (failed-login spikes, impossible-travel logins, sudden export volume), and make sure your error handler returns a clean message instead of a stack trace that maps your internals.
Modern breach detection leans on anomaly monitoring rather than static rules; there's more on how that actually works in our piece on how AI prevents data breaches. For the framework backing, the NIST SSDF (SP 800-218) lays out the respond-and-monitor practices in plain language. Test a restore before launch, not after your database disappears.
What We Actually Find When We Review Our Own Launches
When our team runs a pre-launch security pass on a build, ours or a client's, two misses show up more than anything else. First: a secret riding into the browser on a NEXT_PUBLIC_ prefix, usually a third-party API key someone prefixed to make a client-side call work. Second: at least one endpoint missing its tenant_id scope or an ownership check.
The tenant-scope miss is the scary one because the app looks fine. Every page loads. The bug only shows up when someone changes an ID in the URL. On one review, GET /api/orders/:id returned any order to any logged-in user; a test account read another tenant's orders by incrementing the number. The fix was two lines: compare order.tenantId to session.tenantId before returning.
We won't quote you a fake catch-rate here. What's honest and repeatable is this: the NEXT_PUBLIC_ leak and the missing tenant scope are the two things we find in almost every first-pass review, and both are cheap to fix once you know to look for them. That's exactly why the checklist front-loads them as P0.
If you'd rather have a team run this pass for you before launch day, that's the work we do. Get a pre-launch security review →
About the Author
Mert Batur Gurbuz is Co-Founder of Techsy.io, where the team ships AI agents, automation systems, and voice/SDR pipelines for B2B clients. He studies at the University of Birmingham and writes about the LLM tooling stack the Techsy team actually uses in production. Connect on LinkedIn.
Frequently Asked Questions
What should be on a SaaS security checklist before launch?
Six categories: secrets and config (keep keys out of the client bundle), auth and access (use a library, add MFA), data and tenancy (tenant_id scoping plus ownership checks), dependencies (npm audit in CI), network and transport (HTTPS, HSTS, CSP, rate limits), and monitoring and response (audit logs, tested backups, a runbook).
Is my SaaS secure enough to launch?
You're ready when the P0 baseline is done: secrets out of the client bundle, tenant isolation on every query, auth on a library, HTTPS with security headers, and a clean dependency scan. Perfection is not the bar. A shipped, monitored app with the baseline covered beats a "perfect" one that never launches.
Do I need a penetration test before launching a SaaS?
Not to launch legally. Prioritize one if you handle payments or PII, target enterprise buyers, or an auditor or investor asks. At MVP stage, spend that effort on the app-layer baseline and the OWASP Top 10 first. A pentest finds more when the obvious IDOR and header gaps are already closed.
Do I need SOC 2 to launch a SaaS?
No. No customer expects SOC 2 from a startup that launched last week. It's an enterprise-sales unlock, not a launch gate, and it takes months. Ship with the app-layer baseline, then start the SOC 2 process when a real enterprise deal needs it, not before.
Should I build my own authentication or use a library like Auth.js, Clerk, or Supabase Auth?
Almost always use a library. Auth.js, Clerk, and Supabase Auth have handled the session, token, and reset-flow edge cases that cause most self-built auth bugs. Rolling your own is defensible only if you have a security engineer and a hard requirement no provider meets, which is genuinely rare.
How do I keep secrets out of my client bundle?
Audit every NEXT_PUBLIC_ and VITE_ prefix, because anything with that prefix ships to the browser. Keep .env out of git from the first commit, store server secrets in a manager, and grep your built bundle (grep -r "sk_live" .next/) before you deploy to catch a leaked key.
How do I isolate tenant data in a multi-tenant SaaS?
Put a tenant_id filter on every query and enforce it at the ORM or repository layer so it's automatic. Enable row-level security and learn its failure modes (pool contamination, async leaks). Add an ownership check to every object-ID endpoint to close IDOR, and scope cache keys and storage paths by tenant.
What security headers does a SaaS need before launch?
At minimum: Strict-Transport-Security (HSTS), a Content-Security-Policy, X-Content-Type-Options: nosniff, X-Frame-Options or frame-ancestors, Referrer-Policy, and Permissions-Policy. Start your CSP in report-only, review violations, then enforce it. The MDN security-headers docs list recommended values for each, and the headers table above summarizes what each one stops.
Is automated scanning like npm audit or Snyk enough?
Necessary but not sufficient. Tools like npm audit, Snyk, and Socket catch known CVEs and malicious packages, but they can't find business-logic and access-control flaws like IDOR or a missing tenant scope. Those need a human, a test account, and an explicit ownership check. Run both: the scanner and a manual pass.
The Bottom Line: A SaaS Security Checklist You Can Actually Ship
You don't need to be perfect to launch. You need the baseline. Close the P0 items first: secrets out of the bundle, tenant isolation on every query, an ownership check on every object endpoint, auth on a library, and HTTPS with headers. If you fix one thing before Friday's launch, make it tenant isolation, because that's the bug that leaks a customer's data with zero warning.
Everything here is runnable today, and none of it requires a compliance budget. Work through the 40 checks, hand the code sections to your dev, and ship. Want a second set of eyes before you go live? Get a free consultation and we'll walk the list with you.