pre-launchindiepentest-101

The 8-item security checklist no one tells indie devs

Eight security checks — with curl commands and code fixes — that every indie dev should run before launch. Auth, BOLA, CSP, CORS, rate-limiting, JWT, secrets, admin endpoints.

Alejandro Rodríguez
10 min

I've reviewed the launch post-mortems of dozens of indie devs who got hacked after shipping their first SaaS. The common thread isn't bad code. It's a checklist no one handed them before they clicked publish.

This is that checklist. Eight items. Each one has: a curl command you can run right now, why the bug matters in plain English, and a concrete fix in under ten lines of code.

If you can check all eight green before you launch, you've eliminated the majority of the low-hanging-fruit attack surface on a typical Next.js + Supabase MVP. You're not done with security — but you've moved from "will definitely get popped on launch day" to "a real attacker will have to actually work for it."

No sales call required. No consulting firm. Eight items in your terminal before launch.

Item 1: Every API endpoint has an auth check

How to check:

# Test an endpoint without a cookie/token
curl -s -o /dev/null -w "%{http_code}" https://your-app.com/api/your-endpoint
# Should return 401. If it returns 200, the endpoint is public — intentional?

Why it matters: API routes in Next.js are not automatically protected. If you forgot to add getServerSession (or your equivalent auth check) to a route handler, it's open to the internet. The route might not be linked in the UI, but it's reachable.

Fix:

// At the top of every API route handler
const session = await getServerSession(authOptions)
if (!session) return new Response('Unauthorized', { status: 401 })

Run this check for every file under src/app/api/. Better yet: write a middleware that protects all /api/ routes by default and use an explicit { public: true } annotation for routes that should be unauthenticated.

Item 2: Per-tenant data scoping (BOLA / IDOR)

How to check:

# Log in as user A, grab a resource ID from the response
# Then change the cookie/token to user B's and request the same ID
curl -s -H "Cookie: your-user-b-session" https://your-app.com/api/items/YOUR-USER-A-ITEM-ID
# Should return 403 or 404. If it returns 200 with user A's data, you have a BOLA.

Why it matters: This is the #1 finding in vibe-coded apps. When Cursor or Lovable generates your route handler, it fetches data by ID. It doesn't automatically add "and verify this belongs to the current user." The result: any logged-in user can read any other user's data by guessing or iterating IDs.

Fix:

// Don't just query by ID — query by ID AND owner
const item = await db.item.findUnique({
  where: { id: params.id, userId: session.user.id }
})
// Returns null if ID doesn't belong to the current user
if (!item) return new Response('Not found', { status: 404 })

The userId: session.user.id clause in the WHERE is the entire fix. One field. Apply it to every route that touches user-owned data.

Item 3: CSP header is set

How to check:

curl -s -I https://your-app.com | grep -i content-security-policy
# Should return a policy. If it returns nothing, you have no CSP.

Why it matters: Content Security Policy is the browser's last line of defense against XSS. Without it, if any XSS exists anywhere in your app (a third-party script, a user input you forgot to sanitize), an attacker can load scripts from their own server, exfiltrate session cookies, or deface your app.

Fix (Next.js):

// next.config.ts — add to headers()
{
  key: 'Content-Security-Policy',
  value: "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; connect-src 'self' https://api.your-app.com"
}

Start with this permissive baseline and tighten it over time. Something is better than nothing.

Item 4: CORS is not wildcard with credentials

How to check:

curl -s -I -H "Origin: https://evil.com" https://your-app.com/api/anything | grep -i access-control
# If you see both "Access-Control-Allow-Origin: *" AND "Access-Control-Allow-Credentials: true" — critical bug.

Why it matters: Access-Control-Allow-Origin: * with Access-Control-Allow-Credentials: true is a browser security contradiction. It means any website can make an authenticated request on behalf of your logged-in users. A malicious script on a third-party site can call your API as if it were your own frontend.

Fix:

// Only allow your actual domains
const ALLOWED_ORIGINS = ['https://your-app.com', 'https://www.your-app.com']
const origin = req.headers.get('origin') ?? ''
if (ALLOWED_ORIGINS.includes(origin)) {
  res.headers.set('Access-Control-Allow-Origin', origin)
  res.headers.set('Access-Control-Allow-Credentials', 'true')
}
// Never set both wildcard AND credentials

Item 5: Rate-limiting on auth endpoints

How to check:

for i in {1..20}; do
  curl -s -o /dev/null -w "%{http_code}\n" -X POST https://your-app.com/api/auth/signin \
    -H "Content-Type: application/json" \
    -d '{"email":"[email protected]","password":"wrong"}'
done
# Should start returning 429 after a few attempts. All 200s = no rate limiting.

Why it matters: Without rate limiting on login, an attacker can run a credential-stuffing attack — trying millions of email/password combinations from a leaked database — until they find accounts that reuse passwords. It's the most common account takeover vector.

Fix (Upstash Redis with Next.js):

import { Ratelimit } from '@upstash/ratelimit'
import { Redis } from '@upstash/redis'

const ratelimit = new Ratelimit({
  redis: Redis.fromEnv(),
  limiter: Ratelimit.slidingWindow(5, '15 m'), // 5 attempts per 15 minutes
})

const { success } = await ratelimit.limit(req.ip ?? 'global')
if (!success) return new Response('Too many attempts', { status: 429 })

Free tier on Upstash covers indie-scale traffic. No excuses.

Item 6: JWT is signed properly and checked

How to check:

# Decode your JWT (it's base64) and check the algorithm field
echo "YOUR_JWT_TOKEN" | cut -d'.' -f1 | base64 -d 2>/dev/null | python3 -m json.tool
# Should show "alg": "HS256" or "RS256" — NOT "alg": "none"

Why it matters: The classic JWT vulnerability: if your server accepts tokens with alg: none, an attacker can forge a token for any user without knowing the signing key. They just remove the signature and set the algorithm to none. NextAuth and most libraries don't have this by default, but custom JWT implementations sometimes do.

Fix:

import jwt from 'jsonwebtoken'
const payload = jwt.verify(token, process.env.JWT_SECRET!, {
  algorithms: ['HS256'] // explicitly list allowed algorithms
})

Also verify your JWT_SECRET is at least 32 random bytes and is not in your git history.

Item 7: No secrets in the JavaScript bundle

How to check:

npm run build 2>/dev/null
grep -r "sk_\|PRIVATE_KEY\|service_role\|secret_key\|api_secret" .next/static/ 2>/dev/null \
  && echo "SECRET FOUND IN BUNDLE" || echo "Clean"

Why it matters: Next.js environment variables prefixed with NEXT_PUBLIC_ are intentionally included in the client bundle. Variables without that prefix are server-only. The bug happens when you accidentally use a server-only variable in a Client Component.

Fix:

import 'server-only'

// Only use NEXT_PUBLIC_ for truly public values
const apiKey = process.env.STRIPE_SECRET_KEY  // ✓ server only
const publicKey = process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY  // ✓ safe for client

Add the server-only package to any file that imports server-side secrets. It throws at build time if you accidentally import it into a client component — catching the mistake before you ship.

Item 8: Admin endpoints are locked down

How to check:

# Log in as a regular (non-admin) user, grab your session cookie
curl -s -o /dev/null -w "%{http_code}" \
  -H "Cookie: your-regular-user-session" \
  https://your-app.com/api/admin/users
# Should return 403. If it returns 200 or 404 with data, your admin check is missing.

Why it matters: 22 of the 100 apps I scanned had admin endpoints accessible to any authenticated user, not just admins. The UI never showed regular users a link to /admin, but the API route behind it had no role check. Security through obscurity is not security.

Fix:

const session = await getServerSession(authOptions)
if (!session) return new Response('Unauthorized', { status: 401 })
if (session.user.role !== 'admin') return new Response('Forbidden', { status: 403 })
// ... rest of admin logic

Consider adding an IP restriction on top of the role check for especially sensitive admin operations. Defense in depth.

Run the automated version

These eight checks are the manual version of what Sable does automatically. Before you launch, run the agent version — it catches these and more, including the chained attack paths that aren't visible when you check one item at a time.

npx @sable/agent scan https://your-mvp.com

Free, no sales call, in your terminal, before your Product Hunt launch. Made by a dev who got tired of watching founders learn about these bugs from their users instead of their tools.

The $29 Pre-Launch Check adds chained attack paths, patch diffs formatted for Cursor, and a signed PDF you can show to early customers if they ask about security. Check the eight items. Then run the scan. Ship something you're not worried about.