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;
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:
Views run with the owner's permissions by default. My RLS policies on
userswere fine. But a view created bypostgresreads the table aspostgres, and the owner isn't subject to my policies. Readingusersthrough the view skips RLS entirely, with no warning anywhere.Supabase exposes the whole public schema through its API. Anything I create in
publicgets 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';
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';
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"
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);
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;
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)