By the SurfaceCheckr team. Every finding maps to a check we run in production - see our methodology.
Your Supabase table has no row-level security, so your anon key reads all of it
Here is the part that trips people up: the Supabase key sitting in your frontend is supposed to be there. The anon key is designed to be public. Supabase's own docs list it as safe to expose "online: web page, mobile or desktop app, GitHub actions, CLIs, source code." So finding it in your bundle is not the bug.
The bug is what that public key can reach. The key is not the security boundary. Row-level security is. And on a table where nobody turned RLS on, a public key reads every row.
Why the key is fine but the table might not be
Supabase puts a REST API in front of your Postgres database. The anon key lets the browser talk to that API. What stops the browser from reading data it shouldn't is a set of row-level security (RLS) policies you attach to each table: rules like "a user can only select rows where user_id equals their own id."
With RLS enabled and no policy written yet, the table is closed. Supabase's docs are explicit: "Once you have enabled RLS, no data will be accessible via the API when using a publishable key, until you create policies." The inverse is the whole story here. A table with RLS never enabled is wide open to that same public key.
The gap AI-generated apps fall into
Whether a new table gets RLS turned on depends on how you made it. Supabase enables RLS by default on tables created through the Table Editor in the dashboard. Tables created in raw SQL or a migration do not get it automatically, and Supabase says so: "If you create one in raw SQL or with the SQL editor, remember to enable RLS yourself."
That is exactly the path an AI coding assistant takes. Ask a model to "add a waitlist table" and it writes a CREATE TABLE migration, not a click in the dashboard. The table ships. The app works. Nobody wrote the policy, because nobody was thinking about the gate, and the demo looked fine. This is the literal version of the vibe-coder's lament, "vibe code so hard your entire waitlist is visible in the frontend." The waitlist is visible because the query that populates it runs from the browser with the public key, against a table that never got a rule.
This is not hypothetical. A disclosed vulnerability in the Lovable AI app builder, CVE-2025-48757, describes precisely this class: "an insufficient database Row-Level Security policy... allows remote unauthenticated attackers to read or write to arbitrary database tables of generated sites." It is rated 9.3, critical. It is worth noting the vendor disputes it on shared-responsibility grounds, that securing each app's data is the builder's job, not the platform's, which is the honest tension at the heart of vibe coding: the tool ships you a working app, and the parts it left open are still yours to close.
How to check yours
You do not need anything but a browser and the network tab.
- Open your live app, open devtools, and watch the network tab as the page loads. Look for requests to
*.supabase.co/rest/v1/. - For each table those requests hit, ask: should a logged-out visitor be able to read this? Try the same request in a fresh incognito window with no session. If the data still comes back, RLS is not protecting that table.
- In the Supabase dashboard, the database advisor flags tables with "RLS disabled in public." That report is the fast version of the same check.
Whether a table answers a stranger's query is something an outsider can determine without logging in, which is why a passive external scan can flag the shape of it, an API call from your frontend that returns other people's data. What a scan sees from outside is the leak; what it can't see is your policy file. So treat a fired finding as "go read this table's rules," not "we broke in."
The fix is a policy, not a hidden key
Do not try to fix this by hiding the anon key. You can't, it ships to the browser by design, and rotating it changes nothing about the open table.
Enable RLS on every table in an exposed schema, then write the policy that says who may read each row.
-- table created by a migration: -- RLS never enabled, so the -- public anon key reads every row create table waitlist ( email text, created_at timestamptz );
The pattern generalises: turn RLS on for the table, then grant exactly the access the feature needs and nothing more. A public signup form needs insert, not select. A user's own dashboard needs select where the row belongs to them. The moment you write the narrow rule, the public key stops being a skeleton key and goes back to being what it was meant to be: a way to reach the API, gated by the policy you control.
If this article's scenario matches your stack, the neighbouring risk is a secret Supabase key leaking into the frontend, which is a different and worse problem, because the service_role key bypasses RLS entirely. And the general shape, a frontend calling an API that hands back everyone's data, shows up far beyond Supabase.
Read next
- Supabase security: what a stranger can reach with your public keyWhat an attacker sees before they touch your site
- Your tech stack is public. Does that matter?What an attacker sees before they touch your site
- Your WAF is doing nothing if origin.yoursite.com answers the public directlyWhat an attacker sees before they touch your site
- Your staging site is public, and Certificate Transparency just told everyone whereWhat an attacker sees before they touch your site
Find it before someone else does.
Paste your domain. The grade and issue count are free, and you'll see in a couple of minutes exactly what's reachable from outside.
External, read-only scan. We only request public URLs - never log in, never send attack traffic.