DEV Community

Pon
Pon

Posted on

My public view leaked every user's email. The AI called it "partial user data."

Every email address in my users table was readable with just the anon key. The app worked fine, tests were green, and it sat like that for about three weeks.

I was building a side project with Claude earlier this year. At some point I asked for a public profiles page, the kind where visitors can see usernames and avatars without logging in. It did what I asked and created a view:

create view public_profiles as
select id, username, avatar_url, email
from users;
Enter fullscreen mode Exit fullscreen mode

I remember skimming this and thinking it looked reasonable. I had RLS enabled on users, policies tested, the whole thing. When I later asked the AI what the view returned, it described it as "partial user data." Which is technically true. It just didn't mention that the partial data included the one column I'd never want public.

Why RLS didn't save me

Two Postgres behaviors stack up here, and neither one is obvious if you came in through Supabase:

  1. Views run with the owner's permissions by default. My RLS policies on users were fine. But a view created by postgres reads the table as postgres, and the owner isn't subject to my policies. Reading users through the view skips RLS entirely, with no warning anywhere.

  2. Supabase exposes the whole public schema through its API. Anything I create in public gets an endpoint. So the view wasn't sitting unused in the database, it was one HTTP request away for anyone holding the anon key. And the anon key ships in the frontend bundle by design.

So: RLS on, policies correct, and every email still public. Each piece works exactly as documented; together they published a column I never meant to share.

Why the AI didn't warn me

I don't think the AI did anything wrong by its own lights. I asked for a public profiles page and got a working one. The code ran, the page rendered, my tests (which check what the app shows) all passed. There was no test for "what else can be read that I never render." That question only comes up when someone is thinking like an attacker, and code generation doesn't.

Most of the security issues I've found in my own AI-built projects follow this shape. Working code that does slightly more than I intended.

Check your own project in two minutes

List the views in your public schema:

select viewname from pg_views where schemaname = 'public';
Enter fullscreen mode Exit fullscreen mode

For each one, look at which columns it exposes:

select column_name from information_schema.columns
where table_schema = 'public' and table_name = 'public_profiles';
Enter fullscreen mode Exit fullscreen mode

And the honest test — ask your API the way a stranger would, with only the anon key:

curl "https://YOURPROJECT.supabase.co/rest/v1/public_profiles?select=*" \
  -H "apikey: YOUR_ANON_KEY"
Enter fullscreen mode Exit fullscreen mode

If emails, phone numbers, or anything else private comes back, you have my three-week problem.

The fix

Pick whichever fits your case:

Drop the sensitive columns from the view. A public profile needs a username and an avatar. It doesn't need an email.

On Postgres 15+, make the view respect the caller's RLS:

alter view public_profiles set (security_invoker = true);
Enter fullscreen mode Exit fullscreen mode

Now reads through the view run as the person asking, and your policies apply again.

If the view was never meant to be public, revoke API access:

revoke select on public_profiles from anon;
Enter fullscreen mode Exit fullscreen mode

I did the first two. Belt and suspenders felt right after three weeks of not noticing.

I build with AI every day and that's not changing. But I've stopped treating "it works" as the end of the review, because working code can expose more than it renders. I ended up building a small tool that scans for this pattern and a few related ones automatically. If you're on Supabase and want me to take a look at your repo for free, leave a comment and I'll run it.

Top comments (0)