The first time I read about the Tea app, what stuck with me wasn't the breach itself. It was how ordinary it was. In July 2025, someone browsing 4chan found a door that was never locked. The Tea app — a women's dating-safety app that had rocketed up the App Store, built fast and shipped faster — had left a Firebase storage bucket open to the public internet. Directory listing and all. No firewall was bypassed. No zero-day was burned. The bucket simply answered every request that arrived.
What spilled out were tens of thousands of user images, including selfies and photos of government IDs that users had uploaded for identity verification. Days later, a second exposure leaked private messages going back years — conversations about abortion, infidelity, and assault, some carrying phone numbers and physical meeting locations. The data was neither encrypted nor access-restricted. For a product whose entire promise was keeping women safe, the irony was total.
I kept telling myself it was a one-off. A team that moved too fast, cut a corner, got burned. But I'd been watching the same shape show up again and again in apps spun up by AI tools, and at some point I had to admit the obvious: this isn't a freak accident. It's the default failure mode of how a huge slice of software now gets built.
What I noticed: the security boundary moved, and the tooling didn't follow
Here's the pattern I kept seeing. AI coding tools — Cursor, Lovable, v0, Bolt, Replit — now generate full-stack applications in minutes. To make that magic work, they overwhelmingly default to Backend-as-a-Service: Supabase or Firebase. You describe an app, the model wires up a client SDK, a database, auth screens, and storage, and something that looks production-ready renders in your browser.
BaaS is genuinely good infrastructure — I want to be clear about that. But it relocates your security model, and that's the part I think most people ship without registering. In a traditional server app, an authorization check is a line of code sitting directly in the request path — hard to skip, because if you skip it the feature breaks. In BaaS, access control lives outside the request path, in separate declarative artifacts: Postgres Row-Level Security (RLS) policies and Firebase Security Rules. The app's UI works perfectly whether or not those policies exist. "It works" tells you nothing about whether it's safe — and "it works" is exactly the success criterion an AI generation flow optimizes for. The model ships the feature and stops at the doorstep of the one place that actually matters.
The insight that reframed it for me: the key is supposed to be public
This is the single sentence the whole problem hangs on, and it's the one I find myself repeating most often, so read it twice: the Supabase anon key and the Firebase web config in your bundle are public by design. They are not secrets. Anyone can pull them from DevTools in ten seconds, and that is completely fine — if and only if the database enforces row-level access.
The anon key carries the Postgres anon role, which has no BYPASSRLS attribute. Every query it makes is supposed to be filtered by RLS policies. With RLS on and policies written correctly, an exposed anon key is a non-event. With RLS off, that same public key reads every row in the table. The entire security boundary collapses from "protect the key" to one question: is RLS actually enforced? I've watched engineers burn a weekend trying to hide a key that was meant to be visible — guarding the wrong door entirely while the real one stands open.
Why it keeps happening: a time bomb, not a bug
Once I started looking, the reasons it recurs were structural, not a matter of carelessness. The defaults make the unsafe path the path of least resistance. Supabase tables created via raw SQL historically ship with RLS off — you have to run ALTER TABLE x ENABLE ROW LEVEL SECURITY yourself. Firebase "test mode" grants public read and write to your entire database and storage for 30 days using allow read, write: if true. The 30-day timer is its own trap: it manufactures a false sense of safety, then breaks the live app when it expires, pressuring developers to just reopen the rule.
Then I looked at the volume, and that's when "is it just me?" turned into "the numbers say it isn't." AI-authored code rose to 27.4% of all merged production code in Q1 2026, per DX's measurement across 500+ organizations. Supabase reports that more databases were created in 2025 than in all previous years combined. And the quality gap is structural, not stylistic — Veracode tested over 100 models and found that when given a choice between secure and insecure implementations, they picked the insecure one 45% of the time.
Veracode found AI-generated Java failed security tests more than 70% of the time, with Python, C#, and JavaScript between 38% and 45%.
More apps, shipping faster, with fewer security reviews — that is the acceleration mechanism, and it's why I stopped treating the Tea app as a story about one team. The code functions. It just isn't safe, and nobody checked.
The four failure patterns I see over and over
The catalog is small, recurring, and every entry is one config line away from total exposure — and invisible from the rendered UI:
- RLS never enabled / open tables. The table works, the demo works, and the
anonkey reads every row. - Public storage buckets. The Tea pattern. A bucket left readable, often with directory listing on, answering anonymous requests.
service_role/ admin keys in the client bundle. Theservice_rolekey bypasses RLS entirely and grants full admin access. It leaks when it's used in frontend code or inlined through aNEXT_PUBLIC_/VITE_env prefix. If it's in your bundle, RLS cannot save you — rotate it now.- Permissive default rules in production. A leftover
USING (true)policy orallow read, write: if truethat never got tightened.
The receipts: I'm not imagining this
I want to be honest about how I know it's systemic, because it isn't my hunch — it's the public record. When security researcher Matt Palmer crawled 1,645 Lovable-generated projects (CVE-2025-48757), he found 170 sites — 10.3% — readable or writable by unauthenticated requests using only the public anon key, leaking names, emails, addresses, payment data, and third-party API keys. That's the exact shape I keep watching happen, measured.
The Firebase side is older and larger. Avast scanned 180,300 Firebase databases and found 10.7% — about 19,300 — left open to unauthenticated users. A later crawl of 5.2 million domains surfaced 900+ misconfigured Firebase sites exposing roughly 125 million records, including 20 million plaintext passwords. That predates the vibe-coding surge — which is now pouring fuel on a fire that was already burning.
Why I stopped trusting a config review
My first instinct, once the pattern clicked, was the same one I expect you're having: "fine, we'll review the RLS policies." I've learned that fails in practice, and this is the part I'd underline hardest — policies can look correct and still leak.
A stale USING (true) survives a redesign. A policy gets written but RLS is never actually toggled on for that table. A new table ships without anyone wiring policies. The anon role carries an over-broad GRANT nobody audited. Reading the config tells you intent. It does not tell you what an attacker walks away with. The only reliable proof I trust is behavioral: hit the live endpoint, read-only, with the public key, and see whether rows come back.
What I do now: probe before every deploy
So this is the habit I've landed on. For Supabase, query the REST endpoint with the anon key exactly as an attacker would:
curl 'https://<ref>.supabase.co/rest/v1/<table>?select=*&limit=1' -H 'apikey: <anon_key>' -H 'Authorization: Bearer <anon_key>'
A properly secured table returns [], an empty array. If rows come back, RLS is off and that table is world-readable — and because Range headers can page up to roughly 10,000 rows per request, the exposure is bulk, not one record at a time. The behavior is documented in Supabase's Securing your API guide. Then I grep the shipped JS bundle for the literal string service_role and for long JWT-like strings in DevTools → Sources. For Firebase, I check whether storage buckets and database paths read without authentication.
I treat this as a pre-deploy gate, not a one-time audit. Every new table and every redeploy can quietly reopen the hole — which is precisely why a single passing audit lulls teams right back into the Tea app's position.
Test it, don't eyeball it
I keep coming back to those selfies and IDs. The lesson I took isn't that AI coding is bad — it ships real features for real people at remarkable speed, and I use it too. The lesson is that the security boundary moved from imperative code you can't skip into declarative config you can, and the tooling that generates the app doesn't follow it there. You cannot eyeball your way to safety. You have to test whether the public key actually reads your data.
That conviction is exactly why I built KeyLeak Detector — free, open-source, and running 100% locally. It scans JS bundles for exposed and service_role keys, validates whether a found key is still live, and actively probes Supabase and Firebase to confirm RLS is genuinely enforced before you ship. It runs as a Chrome extension, a Python CLI, and a GitHub Action, so the probe fires on every deploy instead of living in someone's memory. Grab it on GitHub and point it at your next build. The door is either locked or it isn't — stop guessing and check.