Anybody can read your vibe-coded site's database, and it takes about a minute
AI · Sep 2026 · 19 min read
A scan of 1,400 deployed vibe-coded apps found 2,038 critical vulnerabilities, 400 exposed secrets and 175 leaks of personal data — bank details included. Almost none of it is clever. The key in your bundle is meant to be public; the lock it was supposed to sit behind is the thing nobody generated.
It takes about a minute, and most of that is reading. Open the site. Open the JavaScript bundle it loads. The project URL and the key are sitting in it. Ask the database for a table nobody linked to, and if nothing was configured to stop you, everything comes back.
That is not a thought experiment. A security firm ran essentially that check against 1,400 deployed applications built with vibe coding tools and published what came back: 2,038 critical vulnerabilities, more than 400 exposed secrets, and 175 separate instances of leaked personal data including bank details. Live sites, real users, found from the outside by somebody who was not trying very hard.
The interesting part is what is missing from that list. No memory corruption, no novel technique, nothing anybody would present at a conference. It is overwhelmingly one finding written out fourteen hundred times: the data was reachable, and nothing checked who was asking.
That is A01 in the OWASP Top 10 , the least glamorous vulnerability class there is and the one that has sat at number one since before any of us had an agent. The bug has not changed. What changed is that the architecture these tools produce has no obvious place to put the check, and nobody notices the absence, because the site works perfectly.
The check used to have a home
In the app you were building three years ago, every request from the browser hit code you wrote. It might have been a controller, a resolver, a middleware — it does not matter. The point is that there was a floor under the client, it ran on a machine the user did not control, and you could not accidentally skip it, because it was the only route to the data.
Nothing was misconfigured to get here. The middle tier was never generated, because nothing in the demo needed it.
What the generator builds instead is a front end and a hosted data API, with the browser talking to the database more or less directly. This is a legitimate architecture. People who know exactly what they are doing choose it on purpose, and its security model is entirely coherent: the database enforces the rules itself, per row, on every query, no matter who asks.
Coherent, and conditional on somebody writing the rules. That is the whole of it. The enforcement moved out of code the agent writes a great deal of, and into policy the agent writes none of, because nothing in the running application ever demands it.
In a three-tier app you had to write code to expose data. In this one you have to write policy to hide it, and the failure mode of writing nothing has flipped.
Defaults have improved and it is worth being precise about them rather than sneering. Create a table through the Supabase dashboard today and row level security is switched on for you. Create one from a migration or the SQL editor on a project that has been around a while, and it can land with grants to the anonymous role and no policies at all — which, in the platform's own words, makes it readable and writable by any role with a grant on it. The agent writes migrations. That is the path it takes.
What the first minute looks like
It is worth walking the actual sequence, because everyone imagines something more dramatic than what happens, and the gap between the two is why this keeps shipping.
Every step is a documented feature used exactly as documented. That is precisely what makes it hard to see coming.
Open the site. Open the bundle. The project URL and the publishable key are in it. Send one request for a table nobody linked to. Read the response.
The key in your bundle is not the secret: The publishable key — anon in Supabase, the web API key in Firebase — is designed to be public and ships in every client on purpose. It identifies the project; it does not authorise anything. Rotating it after an incident feels like a fix and changes nothing at all, because it was never the control. The control is the policy that runs after the key is accepted. If that policy does not exist, the key is a door handle attached to an open doorway.
This is not hypothetical and it is not rare. In March 2025 a researcher scanned 1,645 deployed applications built on one popular platform and found 303 endpoints across 170 of them — 10.3% — returning user data to anybody who asked: personal details, financial transactions, subscription records. It became CVE-2025-48757. Four months later, Wiz disclosed that an entire class of private apps on another platform could be entered by calling an undocumented registration endpoint with nothing but the app's public identifier, bypassing every authentication control including SSO.
Why nothing caught it
Here is the part I find genuinely interesting, because it is not about carelessness. The people shipping these apps are testing them. They click through the whole thing before they share the link. The test passes and the application is wide open at the same time, and both of those facts are entirely consistent.
The feature was fine. The feature is not the thing that was broken.
Every broken access control bug is invisible to a single account. You signed in as yourself, you saw your own three orders, and the screen was correct. The agent had exactly one user too — the one it created while building — so every loop it ran confirmed the same thing your click-through did.
Authorisation is the one feature whose correct behaviour is an empty response, and nothing in a build-until-it-works loop ever asks for one.
It is the same failure I keep running into in other clothes: a check that reports success without testing the thing it claims to test . And if you asked the agent to write tests, they were generated from the same assumption as the code and they pass for the same reason — which is why a test suite needs something adversarial grading it rather than a green tick .
This is the middle of the distribution, not the tail
You can reasonably ask whether the scanned apps are a biased sample of hobbyists. The controlled work says otherwise, and it is worth having the numbers to hand before somebody tells you your caution is a vibe.
Three independent measurements, one direction. The last one is the only sample that was actually deployed and serving real people.
Veracode ran more than a hundred models across eighty coding tasks in 2025 and found 45% of the output introduced an OWASP Top 10 vulnerability. Against cross-site scripting specifically, 86% of samples failed. Against log injection, 88%. And the conclusion worth writing on the wall: increasing model scale did not improve the security of the output. This is not a capability gap that the next release closes.
Then the human half, which is older and sharper. Stanford's study put 47 developers through five security-sensitive tasks with and without an assistant. The group with the assistant wrote less secure code — and was more confident that what it had written was secure. Both halves matter. The first is a tooling problem. The second is why the tooling problem does not get caught downstream.
And GitGuardian's 2026 numbers on secrets: 28.65 million new hardcoded credentials landed in public GitHub commits during 2025, up 34% in a year, with AI-assisted commits leaking at roughly twice the baseline rate — 3.2% against 1.5%. Twice the rate, on a great deal more volume.
What I would not claim from this: None of this shows that a model writes worse security code than the median human writes under the same deadline. The honest comparison is unflattering to everybody. What the numbers do support is narrower and sufficient: the output is insecure often enough that reviewing it is not optional, it arrives faster than anybody reviews, and the person shipping it is more confident than the evidence warrants. That combination is the risk, not the model.
The checks, in blast-radius order
So here is the working list. It is ordered by what it costs you when it is wrong, not by how hard it is to fix, because the cheap ones are mostly at the top anyway.
What the generator hands you, and the check that closes it What you were given What it costs when wrong The check Tables the browser can reach, with no row policy Every row, to anybody with the URL RLS on, deny by default, one policy per table per operation Client-side guards: hidden buttons, protected routes Nothing was hidden — the data was always fetchable Re-run every request with curl and no UI at all Whatever columns the client sends on an update A user sets is_admin on their own row WITH CHECK on the policy, plus column-level grants A storage bucket created public for convenience Invoices, ID photos, anything anybody uploaded Private bucket, signed URLs, path scoped to the owner A third-party key called straight from the browser Your Stripe or model bill, and the data behind it One server function, authenticated, rate-limited An admin key that appears wherever a policy got awkward Everything, all at once, with no policy in the way grep the built bundle, not the source No log of any request that was refused or allowed You find out when somebody posts a screenshot One alert on unauthenticated 200s and row-count spikes
The first row is the one that pays for the afternoon. Enabling row level security on a table is not a hardening step to schedule for later — an exposed table with RLS off does not have weak rules, it has no rules, and every query passes. Turning it on flips the default to deny, which means the next thing you do is write down what you actually meant.
-- RLS off is not "no policy yet". It is "every policy
-- passes", for anyone holding the publishable key.
alter table orders enable row level security;
create policy "read own orders" on orders
for select using (auth.uid() = user_id);
create policy "insert own orders" on orders
for insert with check (auth.uid() = user_id);
-- USING filters what they may see. WITH CHECK filters
-- what they may write. A policy with only USING lets
-- somebody update their own row into yours.
The third row is the one that gets missed by people who did everything else right, and it is worth its own paragraph. Your policy says a user may update their own profile. It says nothing about which columns, and the client sends the whole object it was given. So the answer to “can a user make themselves an administrator” is yes, by editing a JSON body, unless you wrote the two lines that say otherwise.
-- The policy allows the row. Nothing allows the column.
revoke update on profiles from authenticated;
grant update (display_name, avatar_url)
on profiles to authenticated;
-- Without those: PATCH /rest/v1/profiles?id=eq.me
-- {"is_admin": true}
-- is a perfectly valid update of your own row.
The two keys, and the one that ends you
Every one of these platforms issues two kinds of credential: the publishable one that goes in the browser and obeys every policy, and the administrative one — service role, admin SDK, server key — that exists precisely to bypass them. The second is not a stronger version of the first. It is the thing policies do not apply to.
The sequence that puts it in the wrong place is depressingly reasonable. Something the agent is building needs to read a table the policy forbids. It tries the publishable key, gets an empty array, and looks for the credential that makes the empty array go away. It finds one. The feature works immediately, and the explanation in the chat window is fluent.
# .env — the name of the variable decides the outcome.
VITE_SUPABASE_URL=https://abc.supabase.co
VITE_SUPABASE_ANON_KEY=eyJhbGciOi... # fine
VITE_SERVICE_ROLE_KEY=eyJhbGciOi... # shipped
VITE_STRIPE_SECRET=sk_live_... # shipped
# VITE_ and NEXT_PUBLIC_ mean "inline this into the
# bundle at build time". The build is not going to ask
# whether you meant it.
# Check the artifact, never the source:
grep -rEo 'service_role|sk_live_|AKIA[0-9A-Z]{16}' dist/
Your .env being gitignored proves nothing: The two are unrelated. A variable can be absent from the repository and present in every copy of the bundle your CDN has served, because the build read it and inlined it. The artifact is where the truth lives. Grep dist/ after every build, and put the same grep in CI so that the day somebody renames a variable to make an error go away, the pipeline is the one that notices.
Where the policy quietly stops applying
Three places, all of which look like they are covered and are not. They are worth knowing because they survive the obvious audit — you check the tables, the tables are locked, and the data walks out anyway.
Views. A view over a protected table is created by the postgres user, which means it runs with that user's privileges and bypasses the policies underneath it. Postgres 15 and later can be told otherwise with security_invoker, and the platform documentation says plainly that views bypass RLS by default. An agent reaching for a view to simplify a query has just made a hole in the floor. Functions with SECURITY DEFINER in an exposed schema. They are callable over the data API with the privileges of whoever created them, which is usually a superuser. The function was written to do one useful thing; what it is is a public endpoint running as an administrator. Edge functions that verify a JWT and stop there. Verifying the token proves the caller is somebody. It says nothing whatsoever about whether this somebody may read that record, and the function is usually holding the admin key when it decides.
Confirming that the caller is somebody is not the same as checking that they may do this. Almost every authorisation bug I have found lives in the space between those two sentences.
The agent's own credentials are part of your attack surface
In July 2025 a founder nine days into a public vibe coding experiment declared a code freeze. The agent ran destructive commands against the production database anyway, deleted the live data, generated thousands of fabricated records, and then reported that a rollback was impossible. It was not; the rollback worked. The platform shipped development and production separation shortly afterwards.
Read that as a credentials story rather than a story about a rogue model. Something with a non-zero error rate was holding a connection string that could drop tables, and the only control between it and the data was that it had been asked nicely not to. No amount of prompt discipline fixes that, and this is the same question as who may connect to your MCP server and with whose authority — GitGuardian counted 24,008 unique secrets sitting in MCP configuration files alone.
The rule that makes the whole class go away: The agent gets a development database seeded with fake data, a token scoped to the schemas it is working in, and no path to production — not a read-only production connection, none. Migrations get reviewed and applied by you. If that sounds heavy for a small project, notice that it is the only item on this list that costs nothing to set up and cannot be undone by a confident paragraph in a chat window.
The dependencies nobody chose
One more surface, because it is new and it is easy to check. Researchers generated 2.23 million code samples from sixteen models and looked at what they imported: an average of 5.2% of packages referenced by commercial models did not exist, rising to 21.7% for open-source ones. The names are not random — when the same prompt was run ten times, 43% of hallucinated package names came back every single time.
That reliability is the attack. Somebody registers the name the model reliably invents, waits, and collects install-time code execution from everybody who pasted the command without looking. It has a name now, slopsquatting, which is a sillier word than the thing deserves.
Before installing anything an agent suggested: does this package exist, who publishes it, and did it exist a year ago? Thirty seconds, and it closes the entire class. Commit the lockfile, install with a command that respects it in CI, and treat a new transitive dependency in a diff as a thing to look at rather than noise. Watch for the near-miss names. The most common hallucination is a conflation of two real packages, which is exactly what your eye skims past.
The audit you can run tonight
All of this collapses into about ten minutes of work against your own deployed site. Run it from a machine that is not signed in, and create a second throwaway account before you start, because the second account is the entire point.
BASE=https://your-app.example
# 1. what actually shipped in the bundle
curl -s $BASE | grep -o 'assets/[^"]*\.js'
curl -s $BASE/assets/index-*.js | grep -Eo \
'service_role|sk_live_|AKIA[0-9A-Z]{16}'
# 2. what answers with no session at all
curl -s "$API/rest/v1/profiles?select=*" \
-H "apikey: $PUBLISHABLE" | head -c 400
# 3. what answers for a user who is not the owner
curl -s "$API/rest/v1/orders?select=*" \
-H "apikey: $PUBLISHABLE" \
-H "Authorization: Bearer $SECOND_USER_JWT"
# 4. what a user may write to their own row
curl -s -X PATCH "$API/rest/v1/profiles?id=eq.$ME" \
-H "Authorization: Bearer $SECOND_USER_JWT" \
-d '{"is_admin": true}'
# The answer you want from 2, 3 and 4 is zero rows.
# A 200 with an empty array is a pass. A 200 with
# anything in it is tonight's work.
Then get a model to do the enumeration, which is the tedious half and the half it is genuinely good at. In a fresh conversation, not the one that built the thing — the session that wrote the code will defend it, fluently, and you will believe it.
# Fresh conversation. Paste the schema and the client code.
List every table, bucket and function this app can reach
from the browser. For each one: which policy governs it,
what a valid token from a different user gets back, and
which columns a client can set that it should not.
Then write the curl that proves each answer.
Do not tell me whether it is secure.
The last line is doing the work, for the same reason it does in any review of generated code . Ask for a verdict and you get reassurance shaped like a verdict. Ask for an enumeration and a set of commands, and you get the thing you can actually run.
Where none of this applies
The failure mode of a piece like this one is somebody reading it and applying the full ritual to a weekend project, at which point the tools have lost most of their point. So, plainly, the cases where you should skip all of it.
No users, no data, no money: a personal tool, a visualisation, a thing you and nobody else opens. Ship it, enjoy it, skip every line above. A static marketing site with no database behind it. There is no authorisation to get wrong, and the whole category collapses to keeping the deploy token out of the repository. Anything you are going to delete. If it has the disposal ending, it does not need the checklist — it needs the deletion to actually happen.
It is also fair to say the platforms have moved. The dashboard now warns on exposed tables without policies, the SSO bypass was fixed within twenty-four hours of disclosure, and the development-production split that should have existed all along now exists. The defaults in 2026 are meaningfully better than the defaults in 2025, and anyone still quoting a March 2025 incident as though nothing has changed is arguing with a screenshot.
What has not moved is the shape. The middle tier is still not being generated, because nothing in the demo needs it, and the checklist above is almost entirely a list of things you now have to ask for by name.
What I actually do
Four things, and the first one takes ten minutes. Before anything gets a public URL, I write down every table, bucket and function the browser can reach, and I write the policy for each — deny first, then the narrowest thing that makes the feature work. The list is usually shorter than I expect, which is the reassuring part.
Second, I keep a second test account and a file of curl commands, and I re-run both at the end of every session where an agent touched the schema. It takes under a minute and it is the only thing on this list that catches a regression rather than an original sin.
Third, anything that spends money or holds an administrative key lives behind one server function that I have read line by line, and it is the one file in the repository I do not let anything generate unreviewed. Fourth, nothing generated ever holds production credentials.
And the honest framing to end on, because the sneering version of this argument is both popular and useless. These tools did not make anybody careless. They removed the layer where the check used to be forced on you, and the check did not become unnecessary — it became unstaffed. The minute at the top of this piece is not a demonstration of anybody's skill. It works because nobody has ever run it against their own site.
References Escape, The State of Security of Vibe Coded Apps — 1,400 deployed applications scanned: 2,038 critical vulnerabilities, 400+ exposed secrets, 175 instances of exposed personal data including bank details.Veracode, 2025 GenAI Code Security Report — 100+ models, 80 coding tasks. 45% of output introduced an OWASP Top 10 flaw; 86% failed on XSS, 88% on log injection; larger models were not safer.Perry et al., Do Users Write More Insecure Code with AI Assistants? (ACM CCS 2023) — 47 developers, five security-sensitive tasks. The assisted group wrote less secure code and was more confident it was secure.Matt Palmer, Statement on CVE-2025-48757 — 1,645 deployed apps scanned in March 2025; 303 endpoints across 170 of them returning user data with no row level security.Wiz, Critical vulnerability in vibe coding platform Base44 — Registration and OTP endpoints that needed only a public app id, bypassing every authentication control including SSO. Fixed within 24 hours.GitGuardian, The State of Secrets Sprawl 2026 — 28.65M new hardcoded secrets in public commits during 2025, up 34%. AI-assisted commits leak at 3.2% against a 1.5% baseline; 24,008 secrets in MCP configs.Spracklen et al., We Have a Package for You! (USENIX Security 2025) — 2.23M generated samples. 5.2% of packages from commercial models did not exist, 21.7% from open-source ones, and 43% of invented names repeated across all ten runs.Supabase, Row Level Security — A table in an exposed schema without RLS is readable and writable by any role with a grant. Views and security definer functions bypass policies by default.OWASP Top 10:2021 — A01 Broken Access Control — The category this whole piece is about, at number one since long before any of this was generated.The Register, Replit deleted a production database during a code freeze — The agent ran destructive commands against production, fabricated records, and reported that rollback was impossible. It was not.
Takeaways
The publishable key in your bundle is meant to be public and is not the vulnerability. The row policy it was supposed to sit behind is the security control, and on a generated project it frequently does not exist. These tools produce a front end talking to a hosted database with no middle tier, so the place the access check used to live is simply absent. You now have to write policy to hide data, where you used to write code to expose it. Every broken access control bug is invisible to a single account. Create a second test user before you share the link — it is the cheapest check on the list and it catches the most expensive failure. Row level security off is not "no policy yet", it is "every policy passes". Enable it, then write one policy per table per operation, with WITH CHECK on anything that writes. A policy that allows updating your own row says nothing about which columns. Without column-level grants, a user can set is_admin on themselves with one PATCH request. Views and SECURITY DEFINER functions bypass row policies by default, and an edge function that verifies a JWT has confirmed who the caller is, not what they may do. Grep the built bundle, not the source. VITE_ and NEXT_PUBLIC_ inline a variable at build time, so a gitignored .env proves nothing about what your CDN is serving. Veracode found 45% of AI-generated code introduced an OWASP Top 10 flaw and that bigger models were not safer; Stanford found assisted developers wrote less secure code while being more confident it was secure. The agent gets a development database and a scoped token, never production credentials. The July 2025 database deletion was a permissions story wearing a rogue-AI costume. Ten minutes of curl against your own deployed site — unauthenticated read, second-user read, second-user write — settles most of this. Anything other than zero rows is tonight's work.
All notes · Shehzad Aslam