The right level of security for a solo launch: not zero, not SOC 2. Cover the 15 items on this list and you have eliminated the vulnerabilities that get real indie products breached. You do not need a penetration test. You need to prevent the obvious attacks before they happen.
Most pre-launch security checklists online were written for enterprise teams. They reference WAFs, penetration test scopes, and SIEM platforms that make no sense for a developer who built their app in Cursor and is launching on Product Hunt this Thursday.
The actual risks for a solo SaaS launch are concrete: a hardcoded Stripe key scraped from GitHub, a broken access control bug that lets any user read any other user's data, a missing rate limit that someone runs a brute-force script against, a SQL injection in the search bar. These are not theoretical — they happen to indie products every week, often within days of a Product Hunt launch when the attack surface becomes visible.
This checklist covers exactly those risks. 15 items. Five of them require manual verification. The other ten are things a static analysis scan can check automatically.
In priority order. Items marked AUTO are detected by a Custodia scan. Items marked MANUAL require you to verify directly.
Why: Hardcoded API keys are the fastest path from "public repo" to "compromised account." Bots scan GitHub continuously — a key in a public repo is typically found within minutes.
How to check: Search your codebase for key patterns. Run a secret scan. Check git history with `git log --all -p | grep -E "sk-|aws_|stripe_"`. If you find anything, rotate immediately.
// ❌ DO NOT commit credentials
const stripe = new Stripe('sk_live_4xK...');
const openai = new OpenAI({ apiKey: 'sk-proj-K9m...' });// ✅ Load from environment at runtime
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });Why: Even if you put keys in .env, they get committed if .env isn't ignored. This is the second most common exposure after direct hardcoding.
How to check: Run `git check-ignore -v .env`. If it returns nothing, .env is not ignored. Add it immediately: `echo ".env*" >> .gitignore && echo "!.env.example" >> .gitignore`.
Why: AI editors add authentication middleware globally but frequently miss specific route handlers, API endpoints added late, or webhook handlers that assume all callers are trusted services.
How to check: List every file in your `app/api/` directory. For each one: does it call your auth function at the top, before any data access? One missing check = one open endpoint.
// ✅ Check auth at the top of every handler that needs it
export async function GET(req: Request, { params }: { params: { id: string } }) {
const { userId } = await auth(); // check first
if (!userId) return Response.json({ error: 'Unauthorized' }, { status: 401 });
// now do the data work
const data = await db.query.items.findFirst({ where: eq(items.userId, userId) });
return Response.json(data);
}Why: Authentication checks that the user is logged in. Authorization checks that this user is allowed to do this thing. These are different checks. Missing authorization is OWASP A01 — the #1 web vulnerability category.
How to check: Every endpoint that reads or modifies user-specific data based on a URL parameter (like `/api/documents/[id]`) must verify that the authenticated userId owns the resource at that ID. Simply checking that the user is logged in is not enough.
// ❌ Auth present, authorization absent
export async function DELETE(req: Request, { params }) {
const { userId } = await auth();
if (!userId) return new Response('Unauthorized', { status: 401 });
// ❌ Any authenticated user can delete any document
await db.delete(documents).where(eq(documents.id, params.id));
return Response.json({ ok: true });
}// ✅ Auth + authorization
export async function DELETE(req: Request, { params }) {
const { userId } = await auth();
if (!userId) return new Response('Unauthorized', { status: 401 });
// ✅ Verify ownership before mutation
const doc = await db.query.documents.findFirst({
where: and(eq(documents.id, params.id), eq(documents.userId, userId))
});
if (!doc) return new Response('Forbidden', { status: 403 });
await db.delete(documents).where(eq(documents.id, params.id));
return Response.json({ ok: true });
}Why: SQL injection is still one of the most exploited vulnerability classes. It turns a query parameter into admin access or full data dump.
How to check: If you use a query builder or ORM (Drizzle, Prisma, Kysely), you are mostly protected by default — but check for any `db.execute()` or `db.query()` calls with string interpolation.
// ❌ SQL injection — attacker can modify the query entirely
const result = await db.execute(
`SELECT * FROM users WHERE email = '${email}'`
);// ✅ Parameterized — input is data, never code
const result = await db.query.users.findFirst({
where: eq(users.email, email) // ORM handles escaping
});Why: If your app builds AI features, user input mixed into system prompts is OWASP LLM01 — the most commonly exploited AI vulnerability. An attacker can override your system prompt with a single message.
How to check: Check every LLM call in your codebase. If any user-controlled variable appears inside the `system` message content (not in the `user` message role), it is vulnerable.
// ❌ User input in system prompt = prompt injection
messages: [{
role: 'system',
content: `You are a helpful assistant. User context: ${userInput}`
}]// ✅ Keep system prompt static, isolate user input
messages: [
{ role: 'system', content: 'You are a helpful assistant.' },
{ role: 'user', content: userInput }
]Why: Without rate limits, an attacker can try millions of password/OTP combinations against your login endpoint. Even with bcrypt, 100 concurrent requests can cycle through a dictionary in hours.
How to check: Your auth provider (Clerk, Auth.js, Supabase) may handle this automatically — verify in the settings. If you have custom auth endpoints, add rate limiting middleware at the edge or with a library like `upstash/ratelimit`.
// Rate limiting with Upstash (works on Vercel Edge)
import { Ratelimit } from '@upstash/ratelimit';
import { Redis } from '@upstash/redis';
const ratelimit = new Ratelimit({
redis: Redis.fromEnv(),
limiter: Ratelimit.slidingWindow(10, '60 s'), // 10 req / 60s per IP
});
export async function POST(req: Request) {
const ip = req.headers.get('x-forwarded-for') ?? '127.0.0.1';
const { success } = await ratelimit.limit(ip);
if (!success) return Response.json({ error: 'Too many requests' }, { status: 429 });
// ... auth logic
}Why: An unprotected LLM endpoint can rack up thousands of dollars in API costs within minutes. This is not just a security concern — it is a survival concern for a bootstrapped product.
How to check: Implement per-user rate limits on any endpoint that calls OpenAI, Anthropic, or other pay-per-token APIs. Your Custodia plan already includes scan limits — apply the same thinking to your own product.
Why: A vulnerable dependency is an attacker-controlled path into your application. npm audit finds known CVEs in your package.json dependency tree.
How to check: Run `npm audit` (or `pip-audit`, `cargo audit`). Fix or audit any High/Critical results. Note: npm audit covers dependencies only — it does not scan your application code.
# Check for dependency vulnerabilities npm audit # For Python: pip install pip-audit && pip-audit # Fix automatically where possible: npm audit fix
Why: HTTP connections expose session tokens, credentials, and user data to anyone on the same network. HSTS prevents browsers from ever connecting over HTTP after the first visited.
How to check: If you deploy to Vercel, Netlify, or Cloudflare, HTTPS is enforced automatically. Set the HSTS header: `Strict-Transport-Security: max-age=31536000; includeSubDomains`.
// Next.js — add HSTS to your headers config
// next.config.js
const nextConfig = {
async headers() {
return [{
source: '/(.*)',
headers: [{
key: 'Strict-Transport-Security',
value: 'max-age=31536000; includeSubDomains'
}]
}];
}
};Why: Detailed error messages tell attackers exactly which libraries you use, where your code fails, and often the structure of your database schema.
How to check: Catch all errors at the API boundary. Log the full error server-side, but return only a generic message to the client. In Next.js: ensure `NODE_ENV=production` — this disables error overlays and stack traces in responses.
// ❌ Exposes stack trace, schema, library versions to the client
export async function GET() {
try {
const data = await db.query...
} catch (err) {
return Response.json({ error: err.message, stack: err.stack }); // ❌
}
}// ✅ Log internally, return generic message
export async function GET() {
try {
const data = await db.query...
} catch (err) {
console.error('[API Error]', err); // log server-side
return Response.json({ error: 'Internal server error' }, { status: 500 });
}
}Why: Without HttpOnly, JavaScript can read session cookies — enabling XSS to steal sessions. Without Secure, cookies travel over HTTP. Without SameSite, CSRF becomes possible.
How to check: If you use Clerk or Auth.js, these are set automatically. If you manage sessions manually: `Set-Cookie: session=...; HttpOnly; Secure; SameSite=Lax; Path=/`.
Why: Unvalidated input is the root cause of injection, logic exploits, and mass assignment vulnerabilities. Parse, never assume.
How to check: Every request body, query parameter, and path parameter should be validated through a schema (Zod is the standard in Next.js/TypeScript). Reject anything that doesn't match.
// ✅ Validate with Zod at the API boundary
import { z } from 'zod';
const CreatePostSchema = z.object({
title: z.string().min(1).max(200),
body: z.string().min(1).max(10000),
// ✅ Only allow fields you expect — prevents mass assignment
});
export async function POST(req: Request) {
const body = await req.json();
const parsed = CreatePostSchema.safeParse(body);
if (!parsed.success) {
return Response.json({ error: parsed.error.flatten() }, { status: 400 });
}
// use parsed.data — only the validated, typed fields
}Why: Rendering user input as HTML without sanitization is stored XSS — an attacker can inject scripts that run for every future visitor who sees the content.
How to check: If you render user content as HTML (rich text, markdown, or AI output), pipe it through DOMPurify or a server-side sanitizer first. If you don't need HTML, render as plain text.
// ❌ Stored XSS — attacker injects script into content field
<div dangerouslySetInnerHTML={{ __html: userPost.content }} />// ✅ Sanitize before rendering
import DOMPurify from 'dompurify';
const safeHtml = DOMPurify.sanitize(userPost.content);
<div dangerouslySetInnerHTML={{ __html: safeHtml }} />
// Or: render as plain text if you don't need HTML
<p>{userPost.content}</p>Why: Without logs, you have no way to detect a breach, understand its scope, or prove what happened. Logs are also required if you ever need to demonstrate compliance or investigate a user complaint.
How to check: Log: login successes and failures (with IP), failed authorization attempts, password changes, payment events, admin actions. Store logs somewhere outside your main application (Axiom, Logtail, or even a separate database table).
// Minimal audit log pattern
async function auditLog(event: {
userId: string;
action: string;
ip: string;
success: boolean;
metadata?: Record<string, unknown>;
}) {
await db.insert(auditLogs).values({
...event,
timestamp: new Date(),
});
}
// Use it on critical paths:
await auditLog({ userId, action: 'login', ip, success: true });
await auditLog({ userId, action: 'delete_account', ip, success: true });Items 01–06, 08, 11–14 are checked automatically by a Custodia scan. One command covers them all.
# Run in your project root npx custodia-cli scan # Takes 3-8 min for a typical Next.js or Node.js project # Checks: secrets, access control, injection, prompt injection, # output handling, error exposure, session security, input validation
┌──────────────────────────────────────────────────────┐ │ CUSTODIA.DEV // PRE-LAUNCH SECURITY SCAN │ └──────────────────────────────────────────────────────┘ [SCAN] OpenAI SDK detected → LLM checks enabled [SCAN] Scanning 312 files... ── CHECKLIST RESULTS ──────────────────────────────── [✓] No hardcoded credentials found [✓] .env excluded from tracked files [✗] Authorization missing — CRITICAL src/app/api/documents/[id]/route.ts:18 DELETE handler: auth check present, ownership check absent [✗] Prompt injection — CRITICAL src/app/api/chat/route.ts:31 — user input in system role [✗] Error disclosure — HIGH src/app/api/users/route.ts:44 — stack trace in response body ───────────────────────────────────────────────────── SCORE: 61/100 · 2 critical · 1 high · 2 medium Fix these before launch → custodia.dev/reports/launch_4mN2p
Even with this checklist done, things go wrong. Have these ready before your launch day:
Before launching a web app, check these categories in priority order: (1) Secrets — no API keys or credentials in source code or git history, all secrets in env variables. (2) Authentication — all routes require auth where appropriate, JWTs validated correctly, session management is not broken. (3) Authorization — every API endpoint checks whether the authenticated user has permission to perform the action, not just whether they are logged in. (4) Injection — all database queries use parameterized statements or ORM bindings, never string concatenation. (5) Rate limiting — all auth endpoints have rate limits to prevent brute force. (6) Dependencies — no known CVEs in your npm/pip/cargo dependencies. (7) HTTPS — all traffic over TLS, HSTS header set. (8) Error handling — stack traces and internal errors not exposed to users.
You need a security scan, not a full security audit. A full security audit (penetration testing, manual code review by a security team) costs $5,000–$50,000+ and is appropriate for Series A+ startups or regulated industries. For a solo developer or indie hacker, an automated static analysis scan covers the vast majority of what matters: OWASP Top 10 vulnerabilities, hardcoded secrets, broken access control, injection flaws, and AI/LLM security if you have those features. Run `npx custodia-cli scan` — it takes 5 minutes and costs nothing on the free tier.
The most common mistake is missing authorization checks — specifically, checking that a user is authenticated but not checking whether they are authorized to access the specific resource. This is OWASP A01 (Broken Access Control) and it consistently ranks as the #1 web application vulnerability. The pattern looks like: the developer adds `if (!userId) return 401`, which blocks unauthenticated requests, but forgets to add `if (userId !== resource.ownerId) return 403`, which would block authenticated-but-unauthorized requests. Every API endpoint that reads or modifies user-specific data needs both checks.
For Node.js: run `npm audit` to check for known CVEs in your package.json dependencies. For Python: run `pip-audit` or `safety check`. For Rust: run `cargo audit`. Note that these tools only cover dependencies — they do not scan your application code for vulnerabilities you introduced. For application-level vulnerabilities (injection, broken access control, secrets), you need a SAST tool like Custodia. Use both together: `npm audit` for dependency CVEs, `npx custodia-cli scan` for application logic vulnerabilities.
Immediate steps after a breach: (1) Take the affected service offline or restrict access if you can do it quickly. (2) Rotate all API keys and credentials that may have been exposed. (3) Contact your hosting provider (Vercel, AWS, etc.) — they have security incident teams and may be legally required to assist. (4) If you collect user data, you likely have legal obligations to notify affected users within 72 hours under GDPR or equivalent laws — consult a lawyer. (5) Preserve logs — do not delete anything that might help you understand what happened. (6) If payment data was involved, contact your payment processor (Stripe, etc.) immediately — they have PCI DSS obligation handling.
Free — 3 scan credits. OWASP Top 10 + OWASP LLM Top 10 + secrets. Results in 5 minutes. See pricing →