Not a bug. The default. Every table you create gets a public REST API, your key ships inside your frontend on purpose, and Row Level Security is the only thing in between — switched off until you switch it on. The whole thing in one line: public endpoint + public key + RLS off = your users table is a URL. One query and one curl tell you which side of that you're on. Both are on the next page.
Three facts that are each fine, and together are the problem
Public API + public key + no policy
Every table gets a REST API automatically. You didn't write that endpoint. It exists the second you hit Save. /rest/v1/your_table is live right now. Your anon key ships in your frontend. By design. It is not a secret, it is in every visitor's browser, and that is how the product is meant to work. RLS is off by default. A new table has no protection at all. Nothing is broken — you just haven't turned the lock.
Security researchers auditing startups built on Supabase keep finding tables that anyone with a browser can read — funded companies, real engineering teams, code review, the lot. It isn't a beginner mistake. It's a default that looks finished, because the app behaves exactly the same whether the door is locked or not.
The 60-second check
Three steps, one minute, no tools. Run them on every project you have shipped — including the ones you think are internal.
1 Find every table you missed
SQL Editor
Anything this returns is readable by the internet. Not "might be". Is. select tablename, rowsecurity from pg_tables where schemaname = 'public' and rowsecurity = false order by tablename;
2 See exactly what a stranger sees
Terminal · no login
curl "https://YOUR_PROJECT.supabase.co/rest/v1/users?select=*&limit=5" \ -H "apikey: YOUR_ANON_KEY"
Rows come back? That's your users table, in public. An empty array or a permission error is what you want. Run it from a machine that isn't logged in — that's the whole point.
3 Hunt the key that ignores every policy
Your own bundle
# open your live site, then search the JS chunks: curl -s https://your-site.com | grep -o 'service_role' | head # or in the browser: DevTools > Sources > search "service_role"
A hit means that key is burned — rotate it, but fix the variable name first or the new one burns on your next deploy. The classic version is a service_role key living in a variable called NEXT_PUBLIC_SUPABASE_ANON_KEY , and Next.js inlines anything NEXT_PUBLIC_ straight into the browser.
The 4 mistakes & the fix
Policy written, RLS never enabled
service_role in the frontend
Policies do nothing until RLS is ON for that table. The dashboard saves a policy on an unprotected table and it looks green and finished. A policy without RLS is a comment.
anon respects RLS. service_role ignores it — every policy, every table, read and write. Server only. Never in a bundle, never in a NEXT_PUBLIC_ variable.
Views leaking around RLS
auth.uid() on every row
A view runs as its creator, not its caller. Lock the table, expose a convenient view on top, and you've quietly reopened the same door.
Written the obvious way it's evaluated once per row. On a big table the policy crawls and you get tempted to switch it off. Wrap it in a select.
THE FIX — COPY, PASTE, CHANGE THE TABLE NAME Turning RLS on with no policy denies everything through the API. That's safe, and it's also the moment your app breaks if it was reading with the anon key. So go table by table, with the policy in the same breath. PRIVATE DATA — EACH USER SEES ONLY THEIR OWN ROWS alter table users enable row level security; create policy "own rows read" on users for select using ((select auth.uid()) = user_id); create policy "own rows update" on users for update using ((select auth.uid()) = user_id) with check ((select auth.uid()) = user_id);
The (select auth.uid()) wrapper is mistake #4 handled — Postgres evaluates it once per query instead of once per row. PUBLIC DATA — ONLY WHAT YOU DELIBERATELY PUBLISHED alter table documents enable row level security; create policy "published only" on documents for select to anon using (is_published = true);
The fix, continued
SERVER-ONLY TABLE — NOTHING REACHES IT FROM A BROWSER alter table payments enable row level security; -- no policies at all: anon and authenticated get nothing. -- your server reads it with service_role, which bypasses RLS by design.
VIEWS — CLOSE THE SIDE DOOR -- Postgres 15+: run the view as the caller, not its owner alter view public_profiles set (security_invoker = on); -- and check what else is exposed: select table_name from information_schema.views where table_schema = 'public';
THE AUDIT, IN ORDER Open your project right now and tick each box honestly. One unchecked box is one public table. Run the pg_tables query. Everything it returns is exposed. Start there, not in your app code. Curl your own project. One request, no login. That's exactly what a stranger gets. Grep your live bundle for service_role. Found it? Rotate the key, then fix the variable name before redeploying. Enable RLS on every public table — including the ones you think are empty or internal. The forgotten ones are the dangerous ones: a duplicate from a migration, a staging copy, an abandoned feature. Write the smallest policy that works. Users read their own rows, the public reads only published rows, everything else goes through your server. Never "allow all and tighten later" — later never comes. Check views and storage buckets too. Both bypass the mental model. A public bucket is the same problem with files instead of rows. Re-curl and keep the receipt. Same request, now an empty array. That's how you know it's fixed, not how you feel about it.
Edges & next steps
Where people get caught out storage
Edge
Public buckets
Functions with the service key
Same exposure, different surface. Invoices and IDs sitting on a public URL.
Fine on the server — as long as the function itself checks who's calling.
Realtime
Subscriptions
Generated apps
Realtime honours RLS. Turn RLS on and it tightens too; leave it off and it streams.
The app works with the door open, so nothing tells you. Add "enable RLS and write the policy" to your prompt.
If you do only one thing: run the pg_tables query. It takes five seconds and it tells you the truth about your app — which is more than your dashboard does.
Found this useful? Come build with me.
More no-fluff guides, product breakdowns & behind-the-scenes — every week.
instagram.com/sanskarr.tiwari
