Protecting a vibe-coded SaaS app
We already wrote the checklist of what breaks on an AI-generated app. This post is the other half: not a list of holes to patch once, but a system that keeps the holes from coming back as your AI keeps writing code.
Because that's the real problem with a vibe-coded SaaS. It's not that Cursor wrote one insecure route. It's that Cursor will write another one next week, and another the week after, and you will not be reading every diff. The fix has to be structural. Three moving parts:
- Files, guardrails you commit once, that shape everything the AI writes afterward.
- Prompts, how you ask for code so the secure version is the default version.
- Rules, the runtime layer that assumes the AI got something wrong and catches it in production.
Do all three and a security incident stops being "when," and becomes "unlikely, and I'll know within seconds if it happens." Let's build it.
Part 1, The files
An AI coding agent is only as safe as its context. If nothing in the repo tells it your security conventions, it invents them fresh every session, and its defaults are tuned for "make the demo work," not "don't leak the database." So you write the conventions down, once, in files the agent reads on every run.
1. A rules file the agent actually reads
Every serious agent has a project-instructions file: CLAUDE.md for Claude Code, .cursorrules / .cursor/rules for Cursor, .windsurfrules for Windsurf, copilot-instructions.md for Copilot. It gets prepended to the model's context automatically. Most people fill it with formatting preferences. Put security in it too:
# Security rules, non-negotiable
- Never put a secret key in client-side code. Anything
starting with sk_, rk_, whsec_, AKIA, AIza, ghp_,
sk-ant, or a private key stays server-side. Client code
gets pk_ / publishable keys and nothing else.
- Never interpolate user input into a SQL string. Use the
ORM or parameterized queries. No `raw(` with a template
literal, ever.
- Every mutating route checks auth AND ownership: the user
is logged in AND owns the row they're touching.
- Every auth route (login, register, password reset) has a
rate limit.
- Validate every request body against a schema before use.
- File uploads: validate by magic bytes, cap size, never
trust the extension or the client-sent MIME.
- New env vars go in .env.example with a placeholder, never
with a real value.
This is the single highest-leverage file you will write. It costs five minutes and it changes the default output of every prompt for the life of the project.
2. A .gitignore that assumes the AI will slip
The classic vibe-coded incident: the agent creates .env.production to "help," you git add . out of habit, and now your Stripe live key is in a public repo's history forever. Front-run it:
# Secrets, never commit
.env
.env.*
!.env.example
*.pem
*.key
serviceAccountKey.json
firebase-adminsdk-*.json
credentials.json
*.sqlite
/storage/*.log
Then verify nothing already slipped through before you push: git log --all --full-history -- .env. If that returns commits, the secret is already in history, rotate it, then scrub with git filter-repo. A .gitignore only protects files that aren't tracked yet.
3. A web-server rule that hides the dotfiles
Two-thirds of the leaks we find are a reachable /.env or /.git/config, not because it was committed, but because the deploy serves the project root and the framework doesn't block hidden paths. One block fixes both:
# nginx
location ~ /\.(?!well-known) {
deny all;
return 404;
}
Confirm it works: curl -s -o /dev/null -w "%{http_code}\n" https://your-app.com/.env should print 404. Anything else, 200, 403, even a redirect, is a finding.
4. A security-headers baseline
Vibe-coded apps almost never set security headers, because nothing in the happy path requires them. Add them at the edge or in middleware so every response carries them:
Strict-Transport-Security: max-age=31536000; includeSubDomains
X-Content-Type-Options: nosniff
X-Frame-Options: DENY
Referrer-Policy: strict-origin-when-cross-origin
Content-Security-Policy: default-src 'self'
(Loosen the CSP to fit your assets, but start locked and open holes deliberately, not the reverse.)
Part 2, The prompts
The files set the baseline. The prompts are where you either reinforce it or quietly undo it. Three habits.
Ask for the threat model, not just the feature
Compare these two prompts for the same feature:
❌ "Add an endpoint to update a user's profile."
✅ "Add an endpoint to update a user's profile. It must
reject unauthenticated requests, only allow a user to
edit their OWN profile (check ownership, not just auth),
validate the body against a schema, and rate-limit to
10 requests/min per user. Show me the ownership check."
The first gives you a route that trusts a user-supplied id, the classic IDOR that lets anyone edit anyone's row. The second gives you the same feature, secure, because you named the failure modes. The AI is good at satisfying constraints; it's bad at inventing them. So invent them.
Make the AI review its own code
After a chunk of security-relevant code lands, before you move on, ask a fresh review pass with a specific lens:
"Review the code you just wrote as an attacker. For each
route, tell me: what happens with no auth token? With a
valid token but for a DIFFERENT user's resource? With a
malformed body? With 1000 requests in a second? List every
case you did NOT handle."
This catches a surprising amount, because the model reasoning about attacks is a different task from the model writing a feature, and it's genuinely good at the first one when you point it there.
Wire the security tools into the agent itself
The strongest version of this: give the agent a tool that checks code, so it can catch its own mistakes without you asking. If you use Claude Code, Cursor, or Windsurf, the Defen.so MCP server adds tools the agent can call mid-session, guard_code to static-check a snippet for the common vibe-coder mistakes (client-side secrets, SQL concatenation, missing rate limits), and scan_repo to sweep a repo for committed secrets. Add it once:
// ~/.claude/mcp.json (or your editor's MCP config)
{
"mcpServers": {
"defenso": {
"command": "npx",
"args": ["-y", "@defen.so/mcp"],
"env": { "DEFENSO_TOKEN": "df_live_…" }
}
}
}
Then a line in your rules file, "After writing code that touches auth, the database, env, or a request body, run guard_code on it", turns the check into a reflex the agent performs on its own.
Part 3, The rules (runtime)
Here is the uncomfortable truth that files and prompts can't fix: you will still ship a bug. Everyone does. A route you forgot to guard, a validation you thought the framework handled, a dependency with a CVE published the day after you deployed. The final layer assumes exactly that, and stands between the mistake and the attacker.
One line that covers the whole app
A runtime security layer inspects requests before they reach your code and blocks the OWASP classics, SQL injection, XSS, path traversal, the .env and .git probes, bot floods, credential stuffing, regardless of whether the route behind it was written securely. For a vibe-coded app that's the entire point: it protects the routes you forgot about.
# installs the right SDK for your framework,
# wires the middleware, writes DEFENSO_TOKEN= to .env
npx @defen.so/init
That's one line for Express, Next.js, Laravel, FastAPI, Django, Go, Rails, the CLI detects the framework and wires it the way that framework expects. It fails open: if our API is ever unreachable, your app keeps serving traffic from a cached policy. You lose protection during our incident, never availability.
Scan for secrets you already leaked
Files and rules stop new leaks. But you may already have one, a key committed three weeks ago, an exposed backup, a dump.sql the AI generated and forgot about. A repo/URL scan finds those:
# from your AI editor, via the MCP tool:
scan_repo("github.com/you/your-app")
# or point a pentest at the live URL, it probes
# /.env, /.git, /backup.zip, /dump.sql and reports
# WHICH credentials leak: Stripe, AWS, database URLs,
# AI keys, private keys, not just AI keys.
The distinction matters. A lot of tools only flag AI/LLM keys because those are trendy. A leaked sk_live_ Stripe key or a postgres://user:pass@host database URL is a far worse day. A good scan catches every credential shape, and tells you the exact kind so you know what to rotate first.
Know the moment it starts
The last piece is visibility. If someone begins probing your app right now, you want to hear about it today, not from a customer next week. A real-time attack log gives you the URL, the payload, the IP, the ASN, the country, and the verdict for every blocked request. You get a single daily heads-up in-app when attacks land, so you know your defenses are working without a hundred pings for a hundred requests.
Before you trust any of this, prove it. Fire the real payloads at a protected origin and watch what a working layer does:
- playground.defen.so runs SQLi, XSS, path traversal, XXE, NoSQL, brute force, the
.envprobe and more against a live Defen.so-protected app. Watch each get blocked, deceived, or logged, then fire the same payload at your own URL and compare.
The whole system, in order
If you do nothing else, do these, top to bottom. Each one takes minutes.
- Add the security block to your
CLAUDE.md/.cursorrules. (5 min, changes every future prompt.) - Harden
.gitignore, thengit log --all -- .envto check nothing already leaked. - Add the nginx dotfile block; confirm
curl .../.envreturns 404. - Set the security headers baseline.
- Start writing prompts that name the threat model and ask for the ownership check.
- Wire the MCP server so the agent can check its own code.
npx @defen.so/initfor the runtime layer that catches what slips through.- Run one repo/URL scan to clear any secret you already leaked.
The AI wrote your app. It didn't do the security part, and it will keep not doing it, every session, unless you build the system that makes secure the default. Files, prompts, rules. Set it up once and it holds while you ship.
The free tier covers a real one: one site, live attack log, uptime monitor, a monthly pentest, the managed runtime layer, and the MCP tools, no credit card. Start there, and you're past the 95th percentile of shipped indie SaaS before lunch.