Your login screen is not protecting your database
Someone shipped a paid job board with the one setting that protects its data left switched off. The key I used to read every table is supposed to be public. That was the whole problem.
I came across a job board a while back. Someone had vibe-coded the whole thing. It scraped job postings from Greenhouse, Bayt, NaukriGulf, Indeed, and a few other sources, compiled them, categorized them by department, and listed them in a web app. Not a bad idea on paper.
But it asked for payment on every sign-up before you could see or apply to any of the listings.
I opened the site and it was clear straight away how it was built. The generic UI, the shiny glossy buttons, the colour theme, em-dashes across all the copy (except the job descriptions, which came from the recruiters themselves). The sign-up page. A "Sign up with Google" button that was broken, and when I reported it, it got removed instead of fixed.
So I was fairly sure the person who built this didn't know much backend. And I decided to test that.
And boy was it simple.
Into the bundle
Claude and most other AI tools have a habit of building these apps on Supabase (I'm still not sure why that is), so I had a good suspicion that was the case here too. I pulled up the JavaScript bundles, and there it was: the generic AI code, createClient calls, Supabase calls, API calls, all of it.
Here is the thing about Supabase. The browser talks to the database directly, through Supabase's client library, instead of going through your own server. That's the fast way to build, and there's nothing wrong with it. But it means your server code isn't the gate. It isn't in the loop for those calls at all. Every table is exposed as an auto-generated REST API, and the only thing standing between the public and your data is Row-Level Security (RLS). If RLS is off, the API does exactly what it was built to do: it treats the request as authorized and hands back the rows. Given that this was a paid SaaS product, I was almost certain the person had no idea RLS existed.
I was right.
What the public key gave me
I pulled the project URL and the publishable key out of the bundle (the key is meant to be public, more on that below), and queried the REST API directly. No login, no session, no password. It returned an OK response, with real rows straight out of production. Same for every table: companies, submissions, listings, user_profiles, etc.
So I could read everything. How many people had signed up. How many had paid. Every listing it had posted, its content, its links.
Then I checked whether I could write too, and I didn't want to touch real data to find out. So I aimed an update at an order ID that couldn't exist. A request that gets refused comes back permission denied. Mine came back success, zero rows changed. The door wasn't locked. There just happened to be nothing behind it at that exact spot.
That was enough. Any anonymous person could read every user's contact details, rewrite any listing, flip their own account to paid, or wipe the tables clean. I could have deleted every profile and every listing, and the person would have had no way of knowing how it happened. All of it with a key that's handed to every visitor.
This wasn't hacking
To be absolutely clear, there was no hacking here in the exploit sense. No injection, no brute force, no leaked secret. The publishable key is supposed to be public. That is the whole point of it. Finding it took about thirty seconds, but finding it was never the vulnerability. What you can do with it is. The only thing that was ever meant to protect those tables was Row-Level Security, and it was never switched on. So the public API did exactly what it is designed to do for a request that is authorized by default. Enabling RLS is the entire fix. One setting.
I reported it. As of writing, it hasn't been fixed. Probably because they don't understand what I'm describing.
The floor you don't get to skip
Here is the assumption that causes this, and I think it's a common one. The app has a login, so the data must need a login too. That feels right and it's wrong. A login screen protects your pages. It does nothing for your data. Those are two different locks, and the database one is the one that matters.
Supabase is deny by default, but only after you turn Row-Level Security on. Secure by default has an asterisk. It's secure once you switch the default on. Every tutorial says this, and it's easy to read straight past it while you're busy shipping features.
This is the same class of mistake as pushing your production .env to a public repo and leaking your keys. A rookie error, the kind anyone with a real backend background wouldn't make. And you can't lean on the AI to catch it for you, because at the end of the day it's a human deploying the project to the cloud, not the model. The AI wrote the createClient call. It was never going to walk into the Supabase dashboard and turn RLS on for you.
The lesson isn't "don't vibe-code." Build what you want. But the moment you're taking payments and storing other people's data, you are running a backend, whether you wrote it or not. And a backend has a floor you don't get to skip.
Get the next one
No drip sequence. Unsubscribe is one click in the first line.