How to Keep Your Vibecoded Apps Secure
// the AI shipped your feature. it also shipped the hole next to it.
> ship fast, leak nothing
AI is incredible at shipping features. It is just as fast at shipping the security hole sitting right next to the feature. The agent gives you a working endpoint in thirty seconds — and that endpoint trusts every input, has no rate limit, and happily logs your API key. The code runs, the demo works, and nobody notices the open door until someone walks through it.
This isn't an argument against vibecoding. I vibecode everything. It's an argument that security is the one area where "it works" and "it's fine" are completely different statements — and the AI won't close the gap unless you make it. The good news: most of the damage in a vibecoded app comes from a small handful of mistakes, and you can get an agent to fix all of them with the right prompts.
The core problem in one line:
An AI optimizes for making the feature work, not for making the feature safe when someone is actively trying to break it. Those are different goals, and only one of them shows up in your demo.
The 3 prompts that make your app safer
If you do nothing else from this post, run these three prompts against your codebase. They cover the mistakes that actually get vibecoded apps owned. Run them one at a time, read every diff, and don't let the agent "fix" things you don't understand.
Prompt #1 — Find what leaks
Secrets in the wrong place is the single most common — and most expensive — vibecoding mistake. An API key hardcoded in a component, a .env committed to git, a secret key shipped to the browser. This prompt hunts all of it down.
"Audit this entire codebase for leaked or mishandled secrets. Find any hardcoded API keys, tokens, passwords, or connection strings. Find any secret that is exposed to the client/browser bundle — including anything wrongly prefixed with
NEXT_PUBLIC_, VITE_, or REACT_APP_. Check whether .env files are gitignored and whether any have already been committed to git history. List every finding with the file, the line, the severity, and the exact fix. Do not change anything yet — just report."
The "report first, don't change anything" instruction matters. You want to see the blast radius before the agent starts rewriting things. And remember: a leaked secret in git history is leaked forever — rotate the key, don't just delete the line.
Prompt #2 — Trust nothing from the user
The second-biggest class of bugs is trusting input. Every request body, query param, header, and URL is attacker-controlled. This prompt forces validation and authorization checks onto every entry point.
"Go through every API route, server action, and backend function. For each one: (1) validate and type every input with a schema (use Zod) and reject anything that doesn't match; (2) confirm the caller is authenticated AND authorized to touch the specific record they're asking for — not just logged in, but allowed to access that row; (3) make sure database queries are parameterized, never string-concatenated. Show me a table of every endpoint, what it currently checks, and what's missing."
Point (2) is the one AI almost always misses. There's a difference between "is this user logged in?" and "is this user allowed to read invoice #4173?" The classic vibecoded bug is an endpoint that takes an id from the URL and returns the record — for anyone. That's an IDOR (Insecure Direct Object Reference), and it's how people end up reading other customers' data by changing a number in the URL.
Prompt #3 — Assume someone is hammering it
Your endpoints will get scripted, scraped, and brute-forced. The login form, the password reset, the "send email" button, and especially anything that calls a paid LLM API. This prompt adds the limits that keep one bad actor from running up your bill or breaking in by sheer volume.
"Add rate limiting to every public endpoint, keyed by IP and by user ID where one exists. Apply stricter limits to auth endpoints (login, signup, password reset) and to anything that costs money per call (LLM, email/SMS, file processing). Return a proper 429 with a
Retry-After header. Tell me which library fits this stack and where the counter should live so it survives across serverless invocations. Then show me every endpoint that still has no limit."
That last clause — "anything that costs money per call" — is the one people learn the hard way. An unprotected endpoint wired to an LLM API is a literal money pump for whoever finds it. Rate limiting isn't just a security control there; it's your invoice's seatbelt.
Why prompts beat a one-off "make it secure":
"Is this app secure?" gets you a vague paragraph and a false sense of safety. Each prompt above names a specific failure mode, demands a per-endpoint report, and forbids hand-waving. Specific in, specific out — that's the whole trick to getting useful security work from an AI.
The details behind the prompts
The prompts are the fast path. Here's what's actually going on underneath, so you can read the diffs the agent gives you and know whether they're right.
1. Environment variables & secret leaks
The mental model: a secret is anything that, if a stranger had it, would ruin your day — API keys, database URLs, signing secrets, OAuth client secrets. The rules are boring and absolute.
- Never hardcode them. They live in environment variables, loaded from a
.envfile locally and from your host's secret manager in production. .envis gitignored, always. Commit a.env.examplewith empty placeholders so collaborators (and your agent) know what's needed, never the real values.- Know the public/private boundary. Anything prefixed
NEXT_PUBLIC_,VITE_, orREACT_APP_gets baked into the browser bundle where anyone can read it. A secret key with that prefix is a published secret. - If it leaked, rotate it. Deleting the line doesn't help — it's in git history and possibly already scraped. Generate a new key and revoke the old one.
// ❌ shipped to the browser — anyone can read this
const key = "sk-proj-abc123...";
const url = process.env.NEXT_PUBLIC_STRIPE_SECRET_KEY;
// ✅ stays on the server, never in the bundle
const key = process.env.STRIPE_SECRET_KEY; // no PUBLIC prefix
2. Input validation
Treat every byte from the client as hostile until proven otherwise. The fix is a schema at the boundary: parse the input, and if it doesn't match exactly what you expect, reject it before a single line of business logic runs. In TypeScript land, Zod is the default.
// validate at the door, reject everything else
import { z } from "zod";
const CreatePostInput = z.object({
title: z.string().min(1).max(200),
body: z.string().max(10_000),
published: z.boolean().default(false),
});
export async function POST(req: Request) {
const parsed = CreatePostInput.safeParse(await req.json());
if (!parsed.success) {
return Response.json({ error: "Invalid input" }, { status: 400 });
}
// parsed.data is now typed AND trusted
}
Validation also kills entire bug classes for free. Parameterized queries (never string-concatenated SQL) stop SQL injection. Escaping output and avoiding dangerouslySetInnerHTML with user content stops XSS. And don't forget the boring ones: cap file upload sizes and check their types, or your "profile picture" endpoint becomes a way to fill your disk.
3. Rate limiting
Without a limit, an attacker gets infinite attempts — at your login, your reset flow, your paid APIs. The pattern is simple: count requests per key (IP and/or user ID) inside a time window, and once they blow the budget, return 429 Too Many Requests.
// sliding-window limiter (Upstash) — survives serverless cold starts
import { Ratelimit } from "@upstash/ratelimit";
import { Redis } from "@upstash/redis";
const ratelimit = new Ratelimit({
redis: Redis.fromEnv(),
limiter: Ratelimit.slidingWindow(10, "10 s"), // 10 req / 10s
});
export async function POST(req: Request) {
const ip = req.headers.get("x-forwarded-for") ?? "anon";
const { success } = await ratelimit.limit(ip);
if (!success) {
return Response.json({ error: "Slow down" }, { status: 429 });
}
// ... handle the request
}
The serverless gotcha: an in-memory counter (a plain variable or a Map) resets on every cold start and isn't shared across instances, so it provides basically zero protection in production. The counter has to live somewhere shared — Redis, your database, or a managed limiter. That's why Prompt #3 explicitly asks where the counter lives.
...and a few more that bite
The big three cover most of the damage, but these round out the list of things AI routinely forgets:
- Authorization > authentication. Logged in is not the same as allowed. Check ownership on every record access, ideally at the database layer (e.g. row-level security) so a forgotten check can't leak data.
- Generic error messages. Don't return stack traces or "user not found" vs "wrong password" to the client — both leak information. Log the detail server-side, return something bland to the user.
- Lock down CORS. The AI loves
Access-Control-Allow-Origin: *because it makes the error go away. Pin it to your actual domains. - Keep dependencies patched. Run
npm audit(and enable Dependabot). A chunk of real-world breaches are just an outdated package with a known CVE. - Security headers + HTTPS everywhere. A Content-Security-Policy,
HttpOnly+Securecookies, and HTTPS-only are cheap wins the agent skips by default. - Don't log secrets. A
console.log(req.headers)that ends up in your logging provider is a slow-motion leak. Scrub tokens and PII before logging.
If a stranger had your repo, your URL, and a week of free time — what could they read, run, or run up? If you can't answer that confidently, you haven't run the three prompts yet.
✅ The pre-ship security checklist [ show checklist ▾ ]
// run before you point anyone at the URL
No secrets in code or git
Run Prompt #1. Confirm .env is gitignored, no secret has a
public prefix, and check history for leaks.
git log --all --full-history -- .env
git grep -nE "sk-|secret|password|api[_-]?key" -- ':!*.example'
Every input validated
Run Prompt #2. A schema guards every route, queries are parameterized, no raw user HTML hits the DOM.
Authz on every record
Not just "logged in" — "allowed to touch this row." Try fetching someone else's record by changing the id in the URL. It should 403.
Rate limits everywhere public
Run Prompt #3. Stricter on auth and paid-per-call endpoints. Counter lives in shared storage, not memory.
Dependencies & headers
Clean audit, locked CORS, secure cookies, HTTPS only, generic error messages.
npm audit --production
Bake it into your agent's rules
Drop the standing rules into CLAUDE.md (or .cursorrules) so new code is born
safe instead of audited later.
# CLAUDE.md — security rules
- Validate every API input with a Zod schema; reject on failure.
- Never expose secrets to the client; no secret gets a PUBLIC prefix.
- Check authorization (record ownership), not just authentication.
- Rate-limit every public endpoint; stricter on auth + paid calls.
- Parameterize all DB queries. Return generic error messages.
None of this slows down vibecoding. It's three prompts and a checklist — run them before you share the URL, not after someone shares it for you.
Bottom line
Vibecoding makes you fast, and fast is good — right up until the part of the app you didn't think about is the part someone else did. The AI will never volunteer security; it ships the happy path and moves on. Your job is to ask the questions it won't: what leaks, what does it trust, and what happens when someone hammers it?
Run the three prompts. Read the diffs. Put the rules in your agent's config so the next feature is born safe. It's maybe an hour of work, and it's the difference between a side project and a breach notification.
Links
- OWASP Top 10: owasp.org/www-project-top-ten
- Zod (input validation): zod.dev
- Upstash Ratelimit: github.com/upstash/ratelimit-js
- OWASP Cheat Sheets: cheatsheetseries.owasp.org
Found a hole in your own vibecoded app after reading this? Good — that's the point. Tell me what the prompts caught.