Don't get hacked if you vibecode
Twenty-six holes people get into AI-built apps through, from what you fix this afternoon to what you have to understand
This advanced prompt covers every vulnerability on the list:
Contents
1. Put Cloudflare in front and you have won half the battle
I start here because it is what pays back most for how little it costs: half an hour, free, and it does not touch a single line of your code. Free for real: Cloudflare has a free tier that does not expire and does not ask for a card, and everything a small project needs fits inside it. You point your domain at it, turn on the orange cloud, and from that moment nobody talks to your server directly.
What you get thrown in is a long list: your machine's IP stops being public, the certificate renews itself, known bots fall over before they arrive, you get rate-limit rules and an application firewall with the OWASP rules already written. None of this saves you from a bug in your own code, and I mean that. But it takes the background noise off your hands, and that is 90 % of what a small site receives.
2. HTTPS everywhere, and no other way in
In 2026 this should go without saying, and it does not. I still see sites answering over HTTP, or redirecting to HTTPS but accepting that first request in the clear. The session cookie travels in that first request, and anyone on the same wifi as your user keeps it.
It is three things: a certificate (Cloudflare or your host gives you one, free), a permanent redirect from HTTP to HTTPS, and HSTS, a header telling the browser "never come to this site in the clear again". With all three, the first visit is the only one with a gap, and with HSTS preloading, not even that.
3. The .env never goes into Git
The .env is where your database password, your Stripe key and your mail token live. It is meant never to leave your computer and, even so, it is the file that most often ends up in a public repository. Not through carelessness on your part: nobody told Git to ignore it, and Git does not ask.
The fix is two lines. One in .gitignore saying .env, and a .env.example beside it with the same names and empty values, so whoever clones the project knows what they need without knowing what it is worth. In production the keys go into no file at all: they go into the environment variables panel of Vercel, Railway or wherever you deploy.
4. No key written inside the code
Hardcoding a key means writing it out in the middle of the code, in quotes. It is the first thing the AI does when you ask it to connect to a service, and it makes sense: it is what works on the first try, and it has no way of knowing that file will be on GitHub by Thursday.
The rule fits on one line: the code holds the name of the key, never the key. process.env.STRIPE_SECRET_KEY is a name; sk_live_51H followed by forty characters is a secret. And write it into your project's rules file, the one the AI always reads. One line there saves you checking on every change, which is where you end up slipping.
5. Git remembers what you deleted: rotate the key
Say you pushed a key and spot it half an hour later. You delete the line, commit and breathe out. You have fixed nothing. Git keeps every version, and that key is still there, one command away for anyone who pulls the repository.
And it is not that someone has to be hunting for you specifically: there are bots crawling GitHub full time. The average gap between publishing an AWS key and the first attempt to use it is measured in minutes, not days. The only thing that genuinely fixes a published key is rotating it: go into the service, invalidate it, generate another. Cleaning history with git filter-repo or BFG is fine for leaving no trace, but it is the second step. And so it does not happen again, gitleaks checks every commit before it leaves your machine.
6. Deployment logs talk too
This one slips past everybody, me included. You have the .env out of Git, the keys in the hosting panel, all good. And then your build script does a console.log of the config to debug something, and that config has the key inside it. There it stays, in the deployment log, which on many providers is public if the project is.
The same happens with the error messages you send to Sentry, with CI notifications and with the screenshots you paste into a Slack channel. Do one pass: search your last deployment's logs for the first four characters of one of your keys. If it shows up, you know what comes next.
7. Whatever reaches the browser is public
There is one very clear line in any web application: what runs on your server and what the browser downloads. Everything crossing that line is public. Not "hard to find": public. It sits in a file you open with right click and view source, and minified changes nothing, because minifying is not encrypting.
That is why frameworks mark which variables may travel: in Next they are NEXT_PUBLIC_, in Vite VITE_. If a key carries that prefix, consider it published. Plenty of services hand you two keys for exactly this reason, one publishable and one secret. The secret one never touches the browser: your server makes the call, and the browser talks to your server.
8. Turn debug mode off in production
A detailed error message is a gift to whoever is watching. It tells them which database you use, what your tables are called, which folder the code lives in and which version of each library you have. From there they do not even need to search: the known flaws in that version are published and neatly sorted in case anyone needs them.
In production the user sees a generic sentence and an identifier, and the detail stays in your logs. It is literally one environment variable in most frameworks, and even so things ship in debug mode more often than you would think, because it worked locally and nobody looked again.
9. The test pages that stayed open
Every app built in a hurry has its graveyard: the /test route returning sample data, the /admin/tools built to migrate a table, the database panel left reachable "just for a while", the endpoint that resets the seed. None of them asks for a password, because when they were written it was localhost.
These are not found by guessing: they are found with a tool that tries two thousand common paths in thirty seconds, and anyone has those tools. List everything your application exposes, look at the whole list and delete what should not be there. Delete, not protect: what does not exist cannot be forgotten about.
10. Four headers and a content policy
Security headers are one of those things that sound like a lot and take ten minutes. They go in your framework config or straight into Cloudflare, and each one closes a specific attack: putting your site in an iframe so people click where they do not think they are, letting the browser guess the type of an uploaded file, letting your internal URLs travel when someone clicks away.
The hard one is the content policy, the CSP, because you have to declare where your page may load scripts from and you always break something on the first go. It is worth it anyway: it is the only thing that turns a browser injection bug into a line in the error log instead of a stolen session. Start in report-only mode, watch what complains for a week, then tighten it.
11. CORS: drop the asterisk
CORS is the rule deciding which sites may call your API from a user's browser. At some point something did not work, somebody put an asterisk in to stop the error, and there it stayed. The asterisk means "any website in the world".
On its own it is not the end of the world, because with the asterisk the browser sends no cookies. The problem is the combination everybody reaches for: open origin plus credentials enabled. At that point any page your user visits can make requests to your API with their session attached. List your own domains, which are two, and forget about it.
12. Don't write the login yourself
A login looks like two fields and a comparison, which is why it is the first thing people write by hand. It is not. Behind it there are passwords hashed with the right algorithm rather than the one that sounded good, sessions that expire and can be revoked, a "forgot my password" flow that cannot be used to get into other people's accounts, email verification, a second factor and a limit on attempts.
All of that is built, tested and audited in Clerk, Auth0, Supabase Auth or Better Auth. Wiring it up takes a couple of hours; building it yourself takes weeks and you will still be missing something, because the list above is already too short. And if you can, offer passkeys: they take the password out of the equation, and a password that does not exist cannot be stolen, reused or brute-forced.
13. A rate limit, or they get to guess passwords for free
With no rate limit, your login form is a password-guessing machine running on your bill. It takes nobody special and nothing special: a script on anyone's laptop makes hundreds of attempts a second, and with the usual leaked-password list it lands on more accounts than you would like.
The limit goes in two places. In Cloudflare, with a rule saying five requests a minute to /login from the same IP, and that is five clicks. And in your code, counting attempts per account and not only per IP, because spreading the attack across a thousand IPs is cheap and spreading it across a thousand accounts is useless. Apply it to password resets, verification codes and anything that sends an email, too.
14. Whatever you check in the browser, check again on the server
The interface is not a security measure, it is a convenience for the user acting in good faith. Whoever wants in does not use your interface: they open developer tools, look at the requests you make and repeat them by hand, changing whatever suits them. Hiding a button does not hide the route behind it, and that route keeps answering just as politely.
So every check you do in the browser has to be done again on the server, no exceptions, even when it feels redundant. The browser decides what is visible; the server decides what can be done. If you could only have one of the two, keep the server one: an ugly site nobody empties is a better business than a pretty one they do.
15. Everything closed by default, and you open what belongs open
Almost everyone protects routes one by one, as they remember to. It works until route number thirty, or until you ask the AI for a new endpoint at eleven at night and nobody reviews the list. The hole is hardly ever the obvious admin route: it is the CSV export built for a report that simply stayed there.
Flip it around. Make everything require a session by default and make the public pages a short, explicit list: a middleware at the edge, three or four open routes, done. That way a new route is born closed, which is exactly what you want when you did not write it. Opening one too many shows up the same day, because somebody cannot get in; leaving one open by accident never shows up at all.
16. Every user sees their own and nothing else
You are on /invoices/1042 and you see your invoice. You change the number to 1041 and you see somebody else's. That has a name, IDOR, and it is by far the flaw I ran into most when this was my job. It needs no technique and no tool: it needs an arrow key and curiosity, which is why people find it without even trying.
It happens because the query asks "give me invoice 1042" instead of "give me invoice 1042 belonging to this user". The identifier always comes from outside and is never trustworthy; who you are always comes from the session. And when it does not match, answer 404 rather than 403: a "you don't have permission" already confirms the record exists, and that is information you are handing over too.
17. The database has a door of its own
Supabase and Firebase hand you a key that goes in the browser to talk to the database directly. It is extremely convenient and part of why an app can be built in a weekend. It also means security no longer lives on your server, because there is no server in between: it lives in the database's own rules, and those ship turned off.
In Supabase it is called Row Level Security and you switch it on table by table. Without it, anyone who opens your site has the key and can ask for the whole table: nothing has to be bypassed, the database simply hands it over. In Firebase they are the security rules, and test mode leaves everything open for thirty days and then emails you, which is an email nobody reads.
18. Files are born public unless somebody says otherwise
This is where the genuinely sensitive things usually live: scanned documents, profile photos, contracts, invoices. And it is the part looked at least, because uploading a file works on the first try and you never think about it again.
S3 buckets, Supabase storage and Firebase storage all share the same pattern: configure nothing and the file is reachable by its URL for whoever has it, and those URLs are guessable more often than they should be. Make them private and serve each file through a signed link that expires in a few minutes. It costs half an hour and it is the difference between "data was leaked" and "nothing happened".
19. A schema at the door, and your code only ever sees good data
Everything arriving from outside is a suggestion: the request body, the URL parameters, the headers and whatever the user uploads. The AI writes the happy path because that is the one you described, and the happy path assumes the price is a positive number, the email has an at sign and the quantity is not minus three. That minus three, in a shop, is a refund.
The answer is not filling the code with scattered checks, which is what you get if you patch it as you go. It is putting a schema at the door: with Zod, Pydantic or whatever your language uses you declare the shape of a valid request and everything else falls over with a 400 before touching anything. One place, once, and from there your code works with data it can trust.
20. SQL injection
Injection is what happens when the text a user types ends up mixed into the order you send the database. If you build the query by gluing strings together, whoever types into your search box can type something the database understands as another order, and it runs it without complaint, because it cannot tell what you wrote from what they wrote.
It is always fixed the same way: parameterised queries. You send the query with holes and the values separately, and the database knows those are data, not orders. Any decent ORM does it for you, so today the danger is not the ORM: it is the one query somebody wrote by hand on a Tuesday because the ORM would not reach. Go find it, you have one.
21. NoSQL injection
Plenty of people assume that using MongoDB means this does not apply to them. It does, and more quietly. In MongoDB filters are objects, and if you drop whatever the user sends straight into a filter, they can send you an object instead of a string.
The classic case is the login: instead of a password they send an operator meaning "not equal to nothing", which is true for any password, and they get in without knowing it. There are no odd quotes, nothing that looks like an attack; it is perfectly valid JSON. It is fixed like everything else on this list: validate the type before using it. If you expect a string, demand a string.
22. Whatever the user uploads
An upload form is a door through which you are letting a stranger write to your server. The extension in the name says nothing, the type the browser declares says nothing either, and "I only accept images" checked in the browser is, as you now know, checked nowhere.
The order that works: size limit first, real type read from the contents and not from the name, a new name generated by you, stored in a private bucket and not in the folder you serve the site from, and served from another domain. That last one sounds excessive and it is not: if you serve someone else's file from your domain, the browser believes it is yours, cookies and all.
23. The libraries nobody has touched in two years
Your application is four thousand packages, eight of which you chose. When a serious flaw turns up in one of them it is published, given a number and documented forever, with instructions. From that moment anyone can check in ten seconds whether your site runs that version.
There is no need to obsess. npm audit once a month and Dependabot switched on in the repository already puts you ahead of most. What I would avoid is the usual thing: installing a package with fifty weekly downloads to save writing ten lines. Before adding a dependency, look at when it was last updated and who is behind it.
24. Backups you have actually restored at least once
This does not stop them getting in, it stops them ruining you. Most incidents that end badly end badly not because of what was taken but because of what was deleted. And there the question is not whether you have backups: it is whether you have ever restored one.
Three things and that is it: make the backup automatic and daily, keep it somewhere else with different credentials (if they get into your account and the backups live in that same account, you have no backups), and restore one, in full, into a test project. Until you restore it you do not know it works, and finding that out on the bad day is an experience I would not wish on anyone.
25. Your GitHub account is the master key
You can do all of the above perfectly and lose it here. Your GitHub account holds the code; Vercel holds the environment variables; Supabase holds the database; the domain account lets someone send your traffic elsewhere. It is a chain, and it breaks wherever there is no second factor.
Half an hour well spent: a second factor on all those accounts and on the email you recover them with, which is the link everybody forgets. A password manager so none of them repeats. And review which third-party apps have permission over your GitHub, because there are usually things there from three years ago you do not remember authorising.
26. And if it has already happened
If you are reading this because it has already happened to you, order matters and the temptation is to do it backwards. The first thing is not understanding what went on: it is closing up. Rotate every key, kill every open session, and if you know where they got in, plug that before anything else.
Then come the logs, and this is where you will be glad you kept them. And then the uncomfortable part: if people's data was taken, it has to be said. In Europe the regulation gives you seventy-two hours to report it, and hiding it costs infinitely more than telling it. Last of all, once nothing is on fire, write down what happened and what you changed. It is the only part you actually learn from.
The full pass
- Cloudflare in front of the domain, orange cloud on.
- HTTPS enforced, HSTS, and cookies with Secure and HttpOnly.
- .env in .gitignore and keys in the hosting panel.
- The code holds the name of the key, never the key.
- A key that reached Git gets rotated. Deleting does nothing.
- No key printed into the deployment logs.
- Nothing secret behind a public prefix: what goes to the browser is public.
- Debug mode off and generic errors facing the user.
- Test routes, panels and seed endpoints deleted.
- Security headers set and a CSP, even if it starts in report-only.
- CORS with a list of your domains, never asterisk plus credentials.
- The login, bought in. And passkeys if you can.
- Rate limits per IP and per account, password resets included.
- Every browser check repeated on the server.
- Everything closed by default and a short list of public routes.
- The identifier comes from the URL; the permission from the session. And 404, not 403.
- Row Level Security on, table by table.
- Private buckets, random names and links that expire.
- A validation schema at the door of every endpoint.
- Parameterised queries, never glued strings.
- In NoSQL, the type validated before it reaches a filter.
- Uploads with a size limit, real type, new name and another domain.
- npm audit monthly and Dependabot on.
- Daily backups, in another account, restored at least once.
- Second factor on GitHub, on the host and above all on your email.
- And a two-line plan for the day it happens: close, look, report.
None of the twenty-six is hard. What is hard is remembering all twenty-six when all you want is for the screen to finally do what you asked, which is exactly the state of mind applications get shipped in. So do not leave it to memory: there is a button at the top of this article that copies the prompt with all of it, paste it into your agent and have it check the project. It writes the code in seconds and it reviews it in seconds; what it does not do is remember on its own. And even if you only do the first five points, you are already ahead of most of what ships today.
Do not memorise it: paste it into your agent
Nobody remembers twenty-six points, least of all at eleven at night with something ready to ship. That is what the "Copy the prompt" button is for, at the top of the article and right here: this whole list turned into a job your agent understands.
You paste it into Claude Code, Cursor or whatever you use, and it hands you a report point by point, with the file and the line, sorted from worst to mildest, touching nothing until you say so. Ten minutes. And whatever comes back red, you already know where to read about it, because it is further up.