You've built your SaaS MVP. It works. Users are signing up. But before you announce your launch on Product Hunt, Hacker News, or Twitter, there are security gaps that could cost you everything.
This isn't theoretical. IBM's Cost of a Data Breach Report puts the average breach cost for small businesses at $120,000. For a startup that hasn't hit product-market fit yet, that number is existential. One leaked database, one compromised payment flow, one exposed API key — and you're dealing with legal liability, customer churn, and a reputation crater before you've even started.
The good news: you don't need a security team or a six-figure budget. You need this checklist.
Why MVP Security Matters
"We'll fix security later" is the most dangerous sentence in startup engineering. Here's why:
You're handling real user data from day one. The moment someone signs up, you're storing their email, password hash, and potentially payment information. You have a legal and ethical obligation to protect it.
Compliance expectations are rising. GDPR, CCPA, and PCI-DSS don't have a "startup exemption." If you process payments or store EU user data, the rules apply to you right now.
Your first breach kills trust before you have any. Big companies survive breaches because they have brand equity. You don't. If your early adopters get compromised, they won't come back. Neither will anyone they tell.
Attackers target the weakest links. Automated bots scan the entire internet for common vulnerabilities. They don't care that you're a two-person startup. If your login page is vulnerable to credential stuffing, they'll find it.
The startup security checklist below covers the 12 things that actually matter before launch. Each item includes practical code examples for Next.js and Supabase — the stack most indie hackers and vibe coders are shipping with today.
The SaaS Security Checklist
1. Authentication: Lock the Front Door
Authentication is the first thing attackers probe. Get this right.
Enable email confirmation. Don't let users sign up with unverified emails. This prevents throwaway account abuse and ensures you can reach users if their account is compromised.
// Supabase auth config — require email confirmation
const supabase = createClient(url, anonKey, {
auth: {
autoRefreshToken: true,
persistSession: true,
},
});
// Sign up with email confirmation
const { data, error } = await supabase.auth.signUp({
email: 'user@example.com',
password: 'strongPassword123!',
options: {
emailRedirectTo: `${origin}/auth/callback`,
},
});
Enforce strong passwords. Minimum 8 characters. Supabase handles hashing with bcrypt, but you need to validate password strength on the client side before submission.
Manage sessions properly. Use short-lived access tokens (the default 1-hour JWT in Supabase) with refresh tokens. Never store JWTs in localStorage — use httpOnly cookies instead.
// Next.js middleware — verify session on every request
import { createServerClient } from '@supabase/ssr';
import { NextResponse, type NextRequest } from 'next/server';
export async function middleware(request: NextRequest) {
const response = NextResponse.next();
const supabase = createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
cookies: {
getAll: () => request.cookies.getAll(),
setAll: (cookies) => {
cookies.forEach(({ name, value, options }) => {
response.cookies.set(name, value, options);
});
},
},
}
);
const { data: { user } } = await supabase.auth.getUser();
if (!user && request.nextUrl.pathname.startsWith('/dashboard')) {
return NextResponse.redirect(new URL('/login', request.url));
}
return response;
}
2. Authorization: RLS on Every Table
Authentication tells you who the user is. Authorization tells you what they can access. In Supabase, this means Row Level Security.
Every table in your public schema must have RLS enabled. No exceptions. A table without RLS is accessible to anyone with your anon key — which is public.
-- Enable RLS on all your tables
ALTER TABLE profiles ENABLE ROW LEVEL SECURITY;
ALTER TABLE projects ENABLE ROW LEVEL SECURITY;
ALTER TABLE subscriptions ENABLE ROW LEVEL SECURITY;
-- Users can only read their own data
CREATE POLICY "Users read own data"
ON projects FOR SELECT
USING (auth.uid() = user_id);
-- Users can only modify their own data
CREATE POLICY "Users update own data"
ON projects FOR UPDATE
USING (auth.uid() = user_id)
WITH CHECK (auth.uid() = user_id);
Check which tables are missing RLS:
SELECT schemaname, tablename, rowsecurity
FROM pg_tables
WHERE schemaname = 'public' AND rowsecurity = false;
If this query returns any rows, you have exposed tables. Fix them before launch. For a deeper dive, see our Supabase security checklist.
3. HTTPS Everywhere
This is non-negotiable. Every page, every API call, every asset must be served over HTTPS.
Get an SSL certificate. If you're on Vercel or Netlify, this is automatic. If you're self-hosting, use Let's Encrypt.
Enable HSTS. HTTP Strict-Transport-Security tells browsers to always use HTTPS, even if the user types http://.
// next.config.ts — add HSTS header
const nextConfig = {
async headers() {
return [
{
source: '/(.*)',
headers: [
{
key: 'Strict-Transport-Security',
value: 'max-age=63072000; includeSubDomains; preload',
},
],
},
];
},
};
Eliminate mixed content. One http:// image or script on an HTTPS page and the browser shows a warning. Audit every asset URL.
4. Security Headers
Security headers are the highest-impact, lowest-effort security measure you can add. They tell browsers how to behave and block entire categories of attacks.
// next.config.ts — security headers
{
source: '/(.*)',
headers: [
{
key: 'Content-Security-Policy',
value: "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; connect-src 'self' https://*.supabase.co https://api.stripe.com; frame-ancestors 'none';",
},
{
key: 'X-Frame-Options',
value: 'DENY',
},
{
key: 'X-Content-Type-Options',
value: 'nosniff',
},
{
key: 'Referrer-Policy',
value: 'strict-origin-when-cross-origin',
},
{
key: 'Permissions-Policy',
value: 'camera=(), microphone=(), geolocation=()',
},
],
}
The Content-Security-Policy header alone prevents most XSS attacks by controlling which scripts can execute on your page. X-Frame-Options: DENY blocks clickjacking. X-Content-Type-Options: nosniff prevents MIME-type sniffing attacks.
For a complete walkthrough, see our security headers guide.
5. Input Validation on Every Endpoint
Never trust client input. Validate every field, on every endpoint, on the server side. Client-side validation is for UX — server-side validation is for security.
// Validate input with Zod on your API route
import { z } from 'zod';
const CreateProjectSchema = z.object({
name: z.string().min(1).max(100).trim(),
url: z.string().url().max(2048),
description: z.string().max(500).optional(),
});
export async function POST(req: NextRequest) {
const body = await req.json();
const result = CreateProjectSchema.safeParse(body);
if (!result.success) {
return NextResponse.json(
{ error: 'Invalid input', details: result.error.flatten() },
{ status: 400 }
);
}
// Use result.data — it's validated and typed
const { name, url, description } = result.data;
}
Validate UUIDs, email formats, URL schemes, and numeric ranges. Reject unexpected fields. Cap string lengths. This single practice prevents entire classes of injection attacks.
6. Protect Against SQL Injection, XSS, and CSRF
These are the OWASP Top 10 staples, and they're still the most exploited vulnerabilities on the web.
SQL injection. If you're using Supabase's client library or an ORM, you're mostly safe — queries are parameterized. But if you ever write raw SQL, use parameterized queries:
// Bad — vulnerable to SQL injection
const { data } = await supabase.rpc('search_users', {
query: `SELECT * FROM users WHERE name = '${userInput}'`
});
// Good — parameterized query
const { data } = await supabase
.from('users')
.select()
.ilike('name', `%${userInput}%`);
XSS (Cross-Site Scripting). React escapes rendered content by default, but dangerouslySetInnerHTML bypasses that. Never use it with user input. Sanitize any HTML you must render with a library like DOMPurify.
CSRF (Cross-Site Request Forgery). Validate the Origin header on state-changing requests:
export async function POST(req: NextRequest) {
const origin = req.headers.get('origin');
const allowedOrigins = ['https://yourdomain.com'];
if (!origin || !allowedOrigins.includes(origin)) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
// Proceed with the request
}
7. Secure Your Stripe Integration
Payment security is where mistakes get expensive. Two rules:
Always verify webhook signatures. Never trust a webhook payload without verifying it came from Stripe.
import Stripe from 'stripe';
export async function POST(req: NextRequest) {
const body = await req.text();
const signature = req.headers.get('stripe-signature');
if (!signature) {
return NextResponse.json({ error: 'Missing signature' }, { status: 400 });
}
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(
body,
signature,
process.env.STRIPE_WEBHOOK_SECRET!
);
} catch (err) {
return NextResponse.json({ error: 'Invalid signature' }, { status: 400 });
}
// Now you can trust event.data
}
Never trust client-side amounts. Prices must come from your server or from Stripe's catalog. If a user can set their own price by manipulating a request, they will.
// Bad — price from client
const session = await stripe.checkout.sessions.create({
line_items: [{ price_data: { unit_amount: req.body.price }, quantity: 1 }],
});
// Good — price from Stripe catalog
const session = await stripe.checkout.sessions.create({
line_items: [{ price: 'price_1ABC123', quantity: 1 }],
mode: 'subscription',
});
8. API Rate Limiting
Without rate limiting, a single attacker can brute-force your login, exhaust your database connections, or rack up your Stripe API bill.
Implement sliding-window rate limiting on sensitive endpoints:
// Simple in-memory rate limiter (use Redis in production)
const rateLimitMap = new Map<string, { count: number; resetAt: number }>();
function checkRateLimit(key: string, limit: number, windowMs: number): boolean {
const now = Date.now();
const entry = rateLimitMap.get(key);
if (!entry || now > entry.resetAt) {
rateLimitMap.set(key, { count: 1, resetAt: now + windowMs });
return true;
}
if (entry.count >= limit) {
return false; // Rate limited
}
entry.count++;
return true;
}
// Usage in an API route
export async function POST(req: NextRequest) {
const ip = req.headers.get('x-forwarded-for') ?? 'unknown';
if (!checkRateLimit(ip, 5, 60_000)) {
return NextResponse.json(
{ error: 'Too many requests' },
{ status: 429 }
);
}
// Process the request
}
Apply rate limiting to: login, signup, password reset, checkout, and any endpoint that hits external APIs. For a comprehensive approach, see our API security checklist.
9. Keep Secrets Out of Client Bundles
This is one of the most common mistakes we see when scanning web apps. API keys, database connection strings, and service tokens end up in your JavaScript bundle where anyone can extract them.
Rules for Next.js environment variables:
NEXT_PUBLIC_prefix = visible to the browser. Only use this for truly public values (Supabase anon key, Stripe publishable key).- Everything else stays server-only. Access it only in API routes, server components, and middleware.
# .env.local
# Public — safe to expose
NEXT_PUBLIC_SUPABASE_URL=https://xxx.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=eyJ...
# Private — never in the browser
SUPABASE_SERVICE_ROLE_KEY=eyJ...
STRIPE_SECRET_KEY=sk_live_...
STRIPE_WEBHOOK_SECRET=whsec_...
DATABASE_URL=postgresql://...
Audit your bundle. Run next build and search the .next output for anything that looks like a secret. If you find your service role key in there, you have a critical vulnerability.
10. Dependency Audit
Your app is built on hundreds of npm packages. Each one is a potential attack vector.
# Check for known vulnerabilities
npm audit
# Fix what you can automatically
npm audit fix
# Check for outdated packages
npm outdated
Run npm audit before every launch and set up Dependabot or Renovate for automated pull requests when vulnerabilities are disclosed. Pay special attention to transitive dependencies — the packages your packages depend on.
Lock your dependencies with package-lock.json and commit it to version control. This prevents supply chain attacks where a malicious version of a package gets installed because your version range was too permissive.
11. Error Handling: Don't Leak Stack Traces
A stack trace in production tells an attacker your framework version, file structure, database schema, and which libraries you use. That's a roadmap for exploitation.
// Bad — leaks internal details
export async function GET(req: NextRequest) {
try {
const data = await fetchSensitiveData();
return NextResponse.json(data);
} catch (error) {
return NextResponse.json(
{ error: error.message, stack: error.stack },
{ status: 500 }
);
}
}
// Good — generic error to client, detailed log to server
export async function GET(req: NextRequest) {
try {
const data = await fetchSensitiveData();
return NextResponse.json(data);
} catch (error) {
console.error('Data fetch failed:', error);
return NextResponse.json(
{ error: 'Something went wrong' },
{ status: 500 }
);
}
}
This applies to login and signup too. Don't return "user not found" vs. "wrong password" — that tells attackers which emails are registered. Return a generic "Invalid credentials" for both cases.
12. Automated Security Scanning
You've gone through 11 items manually. But security isn't a one-time task. New vulnerabilities are discovered daily. Dependencies get compromised. Developers push code that accidentally disables a security header.
Automated scanning catches what manual checklists miss. CheckVibe runs 36 security checks against your live application — covering everything in this checklist and more:
- SSL/TLS configuration and certificate validity
- Security headers (CSP, HSTS, X-Frame-Options, and more)
- Exposed API keys and secrets in your client bundle
- XSS, SQL injection, and CSRF vulnerabilities
- Open redirect and CORS misconfiguration
- Cookie security flags
- Dependency vulnerabilities
- Authentication and session management issues
Run a scan before launch. Fix the critical and high-severity findings. Set up scheduled monitoring to catch regressions after every deployment.
What to Skip (For Now)
Not everything matters at the MVP stage. Here's what you can defer until you have revenue, traction, and enterprise customers asking for it:
SOC 2 compliance. This is a lengthy, expensive audit process. No one expects it from a pre-revenue startup. When B2B customers start requesting it, that's the signal.
Penetration testing. A professional pentest costs $5,000-$30,000. At the MVP stage, automated scanning covers 90% of what a pentest would find. Save this for when you're processing significant volume.
Web Application Firewall (WAF). Cloudflare's free tier includes basic DDoS protection, which is enough. A full WAF setup adds complexity you don't need yet.
Bug bounty program. You need dedicated resources to triage and respond to reports. Table this until you have a security-focused engineer on the team.
Advanced threat detection. Log aggregation, SIEM tools, and anomaly detection are valuable at scale. At the MVP stage, basic error logging and Stripe webhook monitoring are sufficient.
Focus on the 12 items above. They cover the vulnerabilities that actually get exploited in the wild against early-stage products.
The 5-Minute Launch Security Check
Short on time? Here's the minimum viable security check before you hit publish:
- Run a CheckVibe scan against your production URL.
- Fix every critical finding. These are the vulnerabilities that can be exploited today with minimal effort.
- Fix high-severity findings. These are exploitable but may require more specific conditions.
- Review the medium findings and fix what you can. Schedule the rest for your next sprint.
- Deploy and set up monitoring. Enable weekly automated scans so you catch regressions.
That's it. Five minutes to identify the gaps, a few hours to fix the critical ones. Your users deserve at least that much.
FAQ
Do I need SOC 2 for an MVP?
No. SOC 2 is a compliance framework designed for enterprise SaaS. It requires months of preparation, policy documentation, and an expensive third-party audit. No customer expects SOC 2 from a startup that launched last week. Focus on the technical fundamentals in this checklist. When enterprise buyers start asking for SOC 2 in their procurement questionnaires, that's when you invest in it.
What security should I have before charging customers?
At minimum: HTTPS everywhere, verified Stripe webhooks, RLS on every database table, input validation on every endpoint, and no secrets in your client bundle. If you're charging people money, you need to protect their payment data and personal information. The 12 items in this checklist are your baseline. If you can't check them all off, fix the gaps before you start processing payments.
How do I secure Stripe webhooks?
Three steps. First, always verify the webhook signature using stripe.webhooks.constructEvent() with your webhook signing secret. Second, check for idempotency — store processed event IDs and skip duplicates, because Stripe retries failed deliveries. Third, return a 200 status only after your business logic succeeds. If you return 200 before processing and then crash, Stripe won't retry and you'll have a payment without a subscription activation. See the code example in item 7 above.
Is automated scanning enough?
Automated scanning catches the vast majority of common vulnerabilities — misconfigured headers, exposed secrets, missing RLS policies, injection flaws, and insecure dependencies. It's the right tool for the MVP stage. But it has limits. It can't understand your business logic, detect authorization bypasses in complex workflows, or find vulnerabilities that require chained exploitation. As you grow, pair automated scanning with manual code review and, eventually, professional penetration testing.
Ship Secure, Ship Fast
The startup security checklist above isn't about being paranoid. It's about being professional. Your users trust you with their data, their money, and their time. The 12 items in this checklist are the minimum bar for earning that trust.
You don't need to be perfect. You need to be better than the automated bots scanning the internet for low-hanging fruit. Run through this checklist, fix what you find, and set up monitoring to catch what you miss.
Ready to check your security posture? Run a free scan with CheckVibe and see exactly where your SaaS stands before launch. 36 security checks. Results in under a minute. No security team required.
For more in-depth guides, check out: