Kalqore
All posts

Pre-launch security guide

You vibe-coded an app. Is it safe to launch?

Five checks to run before you put it on the internet. For each one: why it matters, how to check it in a couple of minutes, and exactly how to fix it, including a prompt you can paste into your AI coding tool.

10 min readBy @thebrokenapp (opens in a new tab)

Check 01

Plaintext passwords

If you can read your users’ passwords, so can anyone who gets into your database.

Databases get leaked all the time: a stolen backup, an exposed admin panel, a bad query. When that happens, what’s stored in the password column decides how bad the day is. People reuse passwords, so a leak of your app’s passwords is also a leak of their email, bank, and Instagram passwords.

How to check2 min

Open your users table and look at the password column.

What you seeVerdict
hunter2Verdict:Plaintext. Fix before launch.
aHVudGVyMg==Verdict:Base64. That’s not security; anyone can decode it in one second. Fix.
2ab96390c7dbe3439de74d0c9b0b1767Verdict:MD5 or SHA. Too fast, so easy to crack. Fix.
$2b$12$Kx9... or $argon2id$...Verdict:bcrypt or Argon2. You’re good.
No password column at allVerdict:Probably fine. Auth is handled by Supabase, Firebase, Clerk, Auth0 etc. They hash for you.

How to fix

Hashing means turning the password into a scrambled string that can’t be turned back. At login you hash what the user typed and compare the two scrambles. You never need to know the real password.

Use a slow hashing method built for passwords: bcrypt or Argon2. Never write your own.

Node.js · npm install bcrypt
const bcrypt = require("bcrypt"); // On signupconst hash = await bcrypt.hash(password, 12);await db.users.insert({ email, password_hash: hash }); // On loginconst ok = await bcrypt.compare(typedPassword, user.password_hash);
Paste into your AI tool
Find everywhere this app stores or checks user passwords. Replace it with bcrypt hashing (cost 12). Write a one-time migration script that hashes all existing passwords in the database. Make sure passwords are never logged or returned in any API response.
Check 02

Rate limits

If anyone can hit your API 10,000 times a minute, your app is one script away from going down.

A rate limit is a rule like “one person can try to log in 5 times per 15 minutes.” Without it:

  • Password guessing. A script tries thousands of passwords on one account until it gets in.
  • Taking you down. Flood any endpoint and your server or database chokes. That’s a Denial of Service (DoS) attack.
  • Burning your money. Spam your signup (each one sends an email or SMS you pay for) or your AI endpoint. You find out when the bill arrives.

How to check2 min

Run this against your own login endpoint and look at the status codes:

Terminal
# Sends 50 login attempts in a rowfor i in $(seq 1 50); do  curl -s -o /dev/null -w "%{http_code}\n" -X POST https://yourapp.com/api/login \    -H "Content-Type: application/json" -d '{"email":"test@test.com","password":"wrong"}'done

All 50 come back 401? No rate limit. You want 429 (“Too Many Requests”) after a handful.

How to fix

Put strict limits on the endpoints that matter most, and a looser limit on everything else:

EndpointSuggested limit
LoginSuggested limit:5 tries per 15 min per IP + per email
SignupSuggested limit:3 per hour per IP
Password reset / OTPSuggested limit:3 per hour per email
Anything calling a paid API (AI, SMS, email)Suggested limit:Per user, sized to what you can afford
Everything elseSuggested limit:~100 per minute per IP
Express · npm install express-rate-limit
const rateLimit = require("express-rate-limit"); const loginLimiter = rateLimit({  windowMs: 15 * 60 * 1000,  // 15 minutes  limit: 5,                  // 5 attempts per window}); app.post("/api/login", loginLimiter, loginHandler);

These limits stop one person with a script. A flood from thousands of machines has to be stopped before it reaches your server: put your domain behind Cloudflare (the free plan covers most of it).

Paste into your AI tool
Add rate limiting to this app. Login: 5 attempts per 15 minutes per IP and per email. Signup: 3 per hour per IP. Password reset and OTP: 3 per hour per email. All other API routes: 100 per minute per IP. Return HTTP 429 with a clear message when the limit is hit.
Check 03

Secrets in your code

If an API key is written in your code, treat it as already stolen.

A secret is anything that lets someone act as you: API keys (OpenAI, Stripe, AWS), database passwords, tokens. Bots scan public GitHub repos around the clock and find leaked keys within minutes. And if your code ships to the browser, anyone can press F12 and read it.

How to check5 min

  1. Search your project for sk-, sk_live, AKIA, api_key, secret, password, token.
  2. Open your live site, press F12, go to the Sources tab, and search the JavaScript files for the same words. Anything you find there, the whole world can find.
  3. Check that .env is listed in your .gitignore.
  4. Check your git history, not just today’s code. A key you deleted last week is still in old commits. Run gitleaks on the repo to scan everything at once.

How to fix

JavaScript
// Bad: key sits in the codeconst openai = new OpenAI({ apiKey: "sk-proj-abc123..." }); // Good: key comes from the environmentconst openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });
  • On your laptop: put secrets in a .env file that’s in .gitignore, so it never gets committed.
  • In production: put them in your host’s environment variables (Vercel, Railway, Render all have a settings page for this), or a proper secrets store like AWS Secrets Manager once you have more than a few.
  • Never in the frontend. Anything prefixed NEXT_PUBLIC_ or VITE_ is sent to every visitor’s browser. Paid API calls (OpenAI, Stripe) must go through your backend.
Paste into your AI tool
Scan this whole project for hardcoded API keys, tokens, passwords and connection strings. Move each one to an environment variable, add a .env.example with placeholder values, make sure .env is in .gitignore, and list every secret that is currently reachable from frontend code so I can move those calls to the backend.
Check 04

Logging

Your app will have errors. Without logs, you’re fixing it with your eyes closed.

After launch, bug reports sound like “it’s not working.” That’s all you’ll get. Logs are what turn that into something you can fix. For every error, you want to know:

QuestionWhat to record
What went wrong?What to record:The error message and stack trace
Who did it happen to?What to record:User ID (not their email or password)
When?What to record:Timestamp
Where?What to record:Page URL or API route, plus the request ID

How to check2 min

Break something on purpose: add a route that throws an error, visit it on your live site, then ask yourself: can I find that error in under a minute, without SSH-ing into anything? If not, you don’t have logging yet.

How to fix

The fastest setup is an error tracker like Sentry (the free tier is plenty to start). It catches crashes on both the frontend and backend, shows you the user, page and time, and emails you when something new breaks.

Next.js
// One command sets up frontend + backendnpx @sentry/wizard@latest -i nextjs // Attach the logged-in user so every error shows who hit itSentry.setUser({ id: user.id });

Besides errors, also log these events so you can spot trouble:

  • Failed logins (lots of them = someone guessing passwords)
  • Password changes and resets
  • Payments and refunds
  • Anything an admin does
Paste into your AI tool
Set up Sentry for this app on both frontend and backend. Attach the user ID to every error. Add structured logs for failed logins, password resets, payments and admin actions, each with timestamp, user ID and route. Make sure passwords, tokens and card numbers are never written to any log.
Check 05

Backups

A backup you’ve never restored isn’t a backup. It’s a hope.

One wrong DELETE without a WHERE. One AI agent that “cleans up” a table. One migration that drops a column. It happens to experienced engineers too. If there’s no recent backup, those 10,000 users are gone, and so is the business.

How to check2 min

Answer these three questions honestly:

  1. Are backups turned on and automatic? Check your database provider’s dashboard. Don’t assume, and read what your plan actually includes. Free tiers often keep few or no backups.
  2. How much would I lose? Daily backups mean up to 24 hours of data lost. If that’s too much, turn on point-in-time recovery (restore to any minute).
  3. Have I ever restored one? If the answer is no, you don’t know if it works.

How to fix

  • Managed database (Supabase, Neon, AWS RDS, PlanetScale, Firebase): turn on automatic backups in the dashboard, and point-in-time recovery if you can afford it.
  • Your own server: schedule a nightly dump and copy it somewhere else, like S3 or Cloudflare R2. A backup on the same server dies with the server.
Postgres
# Nightly dump, run by a cron job at 2ampg_dump "$DATABASE_URL" -Fc -f backup_$(date +%F).dump # Restore into a SEPARATE test database to prove it workspg_restore -d "$TEST_DATABASE_URL" --clean backup_2026-09-21.dump

Do a test restore today

  1. Take your latest backup.
  2. Restore it into a new, empty database (never on top of production).
  3. Count the rows in your main tables and compare with production.
  4. Point a local copy of your app at it and log in.
  5. Write down how long it took. That’s how long you’d be down in a real emergency.
Paste into your AI tool
Set up automated nightly backups for this app’s database, stored off the main server with 30 days of history. Then write a step-by-step restore guide and a script that restores the latest backup into a separate test database and compares row counts with production.
Bonus check

Can users see each other’s data?

This is the most common hole in vibe-coded apps, and the easiest to miss, because everything looks fine when you test with one account.

Here’s how it goes wrong. Your app loads an order with /api/orders/1042. It checks that you’re logged in, but not that order 1042 is yours. Change the number to 1041 and you’re looking at a stranger’s order: their name, address, phone number.

How to check5 min

  1. Create two accounts: A and B.
  2. Logged in as A, create something (a profile, an order, a note). Copy its URL or API call from the Network tab (F12).
  3. Log in as B and open that same URL or repeat that API call.
  4. If B can see or edit A’s data, you have a problem.

How to fix

Every query that reads or changes user data must also check who owns it:

JavaScript
// Bad: returns any order to anyone logged indb.orders.find({ id: req.params.id }); // Good: only returns it if it belongs to this userdb.orders.find({ id: req.params.id, user_id: req.user.id });
  • Using Supabase? Turn on Row Level Security (RLS) for every table and add policies so users can only read and write their own rows. With RLS off, your public anon key lets anyone read the entire table.
  • Using Firebase? Make sure your Firestore rules aren’t still in test mode (allow read, write: if true).
Paste into your AI tool
Go through every API route and database query in this app. For each one that reads or changes user data, check that it only allows the logged-in user to access their own records. List every route that doesn’t, and fix them. If this uses Supabase, enable RLS on every table and write owner-only policies.
Before you launch

The launch checklist

Don’t ship until every box is ticked.

0 of 15 ticked

01 · Passwords
02 · Rate limits
03 · Secrets
04 · Logging
05 · Backups
Bonus · Access

Launching soon?

Want a second pair of eyes on it before it goes live?

Kalqore builds and runs production software. Tell us what you are shipping and we will tell you where it is likely to break.

Let’s talk (opens WhatsApp in a new tab)
All posts