August 4, 2026
How I Found a Critical Bug That Exposed Every User’s PII With a Free Account
A private pentest, one API call, and a lesson every Supabase (or RLS-backed) team needs to hear.

By Abhishek meena
3 min read
Yesterday, during a private penetration test, I signed up for a free account on a SaaS platform, sent one API request, and got back the email, phone number, full name, and business name of every single user on the platform.
No admin access. No exploit chain. No social engineering.
Just a normal, low-privilege, freshly created account — and a misconfigured database policy that treated "logged in" as the same thing as "allowed to see everything."
This is the story of that bug, why it happened, and the one-line fix that would have prevented it.
The Setup
The application was a business SaaS product built on Supabase — a popular Postgres-based backend-as-a-service that a huge number of startups use today.
Authentication itself was solid. Email/password login, Google OAuth, properly issued JWTs. Nothing sloppy at the front door.
The problem wasn't getting in. The problem was what happened after you got in.
The Bug
Supabase relies on Row-Level Security (RLS) — Postgres policies that decide which rows a given user is allowed to read or write. Done right, RLS is one of the best security features available in modern backend tooling: the database itself enforces "you can only see your own data," no matter what the frontend does.
Done wrong, it's a wide-open door.
In this case, the profiles table — which stored user emails, phone numbers, names, and business names — had a SELECT policy that looked roughly like this:
CREATE POLICY "profiles_read_all_authenticated"
ON public.profiles FOR SELECT
USING (auth.role() = 'authenticated');CREATE POLICY "profiles_read_all_authenticated"
ON public.profiles FOR SELECT
USING (auth.role() = 'authenticated');Read that condition again. It doesn't check who the user is. It only checks that they're logged in at all.
That means the policy was never actually scoping data to the requesting user — it was handing over the entire table to anyone with a valid session.
Proving It
Here's what the actual request looked like, redacted:
# Just a normal low-privilege account. No admin. No special access.
curl -s "https://[REDACTED].supabase.co/rest/v1/profiles?select=*" \
-H "apikey: [REDACTED]" \
-H "Authorization: Bearer [REDACTED_JWT]"# Just a normal low-privilege account. No admin. No special access.
curl -s "https://[REDACTED].supabase.co/rest/v1/profiles?select=*" \
-H "apikey: [REDACTED]" \
-H "Authorization: Bearer [REDACTED_JWT]"And the response:
[
{
"id": "[REDACTED-UUID]",
"business_name": "[REDACTED]",
"phone_number": "[REDACTED]",
"email": "[REDACTED]@[REDACTED].com",
"full_name": "[REDACTED]"
},
{
"id": "[REDACTED-UUID]",
"business_name": "[REDACTED]",
"phone_number": "[REDACTED]",
"email": "[REDACTED]@[REDACTED].com",
"full_name": "[REDACTED]"
}
// ..***34 more rows. Full user base, in one call.
][
{
"id": "[REDACTED-UUID]",
"business_name": "[REDACTED]",
"phone_number": "[REDACTED]",
"email": "[REDACTED]@[REDACTED].com",
"full_name": "[REDACTED]"
},
{
"id": "[REDACTED-UUID]",
"business_name": "[REDACTED]",
"phone_number": "[REDACTED]",
"email": "[REDACTED]@[REDACTED].com",
"full_name": "[REDACTED]"
}
// ..***34 more rows. Full user base, in one call.
]One request. Every user. Confirmed with a count check:
Content-Range: 0-0/36 → all ***36 registered users exposedContent-Range: 0-0/36 → all ***36 registered users exposedNo pagination bypass needed. No rate limiting to defeat. It was all just… there.
Why This One Hurt More Than Usual
Every PII leak is bad. This one was worse for a specific reason: phone numbers were the core product. The platform was a WhatsApp-style business messaging tool — meaning the exposed data wasn't incidental, it was the exact dataset an attacker would want most.
With names, emails, and phone numbers of real business owners in hand, an attacker doesn't need to guess who to target. They have a ready-made list for:
- Phishing — emails that reference real business names look far more convincing
- Smishing / WhatsApp-based social engineering — using real phone numbers under the platform's trust context
- Competitive scraping — a full customer list, harvested for free
- Regulatory fallout — unauthorized disclosure of personal data is a reportable incident under most data protection laws (DPDP Act in India, GDPR in the EU, etc.)
A single missing WHERE clause, effectively, turned into a company-wide breach.
The Fix
This is the part that always stings a little — the fix was one line:
DROP POLICY IF EXISTS "profiles_read_all_authenticated" ON public.profiles;
CREATE POLICY "profiles_select_own"
ON public.profiles FOR SELECT
USING (id = auth.uid());DROP POLICY IF EXISTS "profiles_read_all_authenticated" ON public.profiles;
CREATE POLICY "profiles_select_own"
ON public.profiles FOR SELECT
USING (id = auth.uid());Now the policy actually checks who is asking, not just whether they're logged in. A user can only read their own row.
If the app needs shared visibility — say, multiple team members in one workspace — the fix is still simple, just scoped properly:
CREATE POLICY "profiles_select_own_or_workspace"
ON public.profiles FOR SELECT
USING (
id = auth.uid()
OR id IN (
SELECT member_id FROM workspace_members WHERE user_id = auth.uid()
)
);CREATE POLICY "profiles_select_own_or_workspace"
ON public.profiles FOR SELECT
USING (
id = auth.uid()
OR id IN (
SELECT member_id FROM workspace_members WHERE user_id = auth.uid()
)
);Same idea, same core rule: never let "authenticated" stand in for "authorized."
The Real Takeaway
This bug class is more common than people think, and it's easy to see why. RLS feels like it's "handled" — you turn it on, write a policy, and move on. But a policy that's syntactically valid isn't the same as a policy that's actually scoped correctly. Postgres will happily enforce a rule that says "give everyone everything," because that's a perfectly valid rule — it's just the wrong one.
If you're building on Supabase, or any RLS-backed stack, here's the actual checklist:
- Audit every policy on every table —
SELECT,INSERT,UPDATE,DELETE— not just the ones you remember writing. - Test as an attacker, not as a developer. Sign up with a fresh, low-privilege account and try to read data that isn't yours. If you can, so can anyone else.
- Never trust
auth.role() = 'authenticated'alone. That check answers "is this person logged in?" — not "should this person see this row?" - Re-verify after every schema change. RLS policies rot quietly. A table that was scoped correctly six months ago can silently become a leak after one refactor.
The frontend can be flawless. The auth flow can be textbook. None of that matters if the database itself is handing out rows to anyone who asks.