Your Supabase tables are readable by the world — until you turn this on
A table without Row Level Security is not “private by default.” It is readable by any granted role — and the anon role often has one. The fix is small, but you have to write it for every table you create.
September 2026 · Zoltra · No account needed to use this fix
When you create a table in Supabase, the platform gives you a lot. PostgREST is already wired up, the Supabase client can query it from your Next.js app with one line, and the dashboard makes it feel like things just work. That feeling is accurate until it quietly is not — because the default posture of a fresh table, before you add any policy, is permissive in a way that surprises most teams.
A Supabase table with Row Level Security disabled and a grant to anon is, for practical purposes, a public API endpoint. Not in the sense that you published it on purpose. In the sense that someone who has your anon key — which, as we covered in the last post, often ships in your browser bundle — can callGET /rest/v1/profiles?select=* and get rows back. No exploit needed. Just a key and a table name.
This is not a flaw in Supabase. It is the model. Postgres RLS is opt-in by design. Supabase exposes that honestly. The problem is that teams ship fast, create tables for every feature, and forget that each new table starts with zero policy. You do not get an error when you forget. You get a database that answers when anyone asks.
Why this is the most common thing we find
When Zoltra looks at real projects, the pattern is almost always the same. A profiles table, awaitlist table, a notes table, maybe a projects table. Two of them have RLS because someone set it up carefully. The other two were added quickly during a feature sprint and never got a policy. The grant to anon is still there from scaffolding. Nobody noticed because the app queries the data through the authenticated client and it works fine — for the owner. It also works, quietly, for everyone else.
I should be blunt about what “everyone else” means now. In the past you might have said “well, nobody knows my table names.” Table names are guessable and enumerable, and agents are good at trying the obvious ones. An agent that has already pulled your env key from the bundle and knows you are on Supabase will tryprofiles, users, projects, waitlist, messages — not because it is clever, but because that is the cheap thing to try at scale. You do not need a sophisticated attacker for this. You need an untuned agent with your anon key and five minutes.
The other part I want to name plainly: most teams assume their framework handles this. It does not. Next.js does not know about your Postgres policies. Supabase does not enable RLS for new tables automatically. Auth does not imply authorization. You have to say, for each table, who can read what — or nobody can.
I think there is a specific reason people avoid writing policies, too — tbh it feels like you might break your app if you get it wrong. You will, a little, on the first pass. You will add RLS, reload your page, see no rows, and think you broke everything. Then you add the USING (auth.uid() = id) clause, reload, and it works — only for the right person. That two minutes of confusion is why teams put it off. Worth it though. The alternative is finding out your table was open from someone else.
One more thing that surprised me the first time I looked at this across real projects. Supabase has a table editor that shows a little badge when RLS is enabled. It does not yell at you when it is not. There is no red banner that says “this table is open to anon.” The safest posture looks, in the UI, almost the same as the unsafe one — a small toggle difference. If you are not looking at pg_tables or running the grant query, you just do not know. That is the design tradeoff of a platform that empowers you to move fast. You get a lot. You also get a lot of responsibility without a lot of noise.
What a locked table actually looks like
RLS in Postgres is simple in the way a door lock is simple. You turn it on for the table, you remove world access, and then you add a policy that describes who can come in. The minimal shape that covers the common case — users can read only their own rows — is this:
-- 1. Turn on RLS and revoke world access ALTER TABLE profiles ENABLE ROW LEVEL SECURITY; REVOKE ALL ON profiles FROM anon; -- 2. Add the least-privilege policy you actually need CREATE POLICY "own rows only" ON profiles FOR SELECT USING (auth.uid() = id); -- If writes happen through the same table: CREATE POLICY "owners can insert own row" ON profiles FOR INSERT WITH CHECK (auth.uid() = id); CREATE POLICY "owners can update own row" ON profiles FOR UPDATE USING (auth.uid() = id) WITH CHECK (auth.uid() = id);
A few things worth lingering on, because they trip people up. The ENABLE ROW LEVEL SECURITY line alone is not enough. If anon still has a table-level grant, some clients will behave in confusing ways depending on how Supabase resolves roles. Revoking the grant is not optional. And the policy has to be per operation — enabling RLS without any policy means nobody can read anything, including your own app. You have to actually allow the query you want to allow, for the right person.
For tables that are not per-user — say a public features table — the policy looks different, but the rule is the same. Be explicit. Do not rely on “nobody will guess the endpoint.” Write the policy that matches what you intend:
-- Public read, no writes ALTER TABLE features ENABLE ROW LEVEL SECURITY; CREATE POLICY "public can read features" ON features FOR SELECT USING (true); -- No INSERT / UPDATE / DELETE policy = no writes, by anyone, through PostgREST -- Or, authenticated-only read CREATE POLICY "signed-in users can read features" ON features FOR SELECT USING (auth.role() = 'authenticated');
The instinct when you see this is to add one policy and feel done. My honest advice: go further. List every table you have and check each one. The one you forgot is the one an agent will find, because that is the one without a lock.
How to know you are done, in one query
Run this in the Supabase SQL editor while signed in as a regular user or as anon:
-- As anon: expect 0 rows for private tables SELECT count(*) FROM profiles; -- should be 0 for anon, 1 row for the owner on their own id -- As anon: try the thing an agent would try SELECT * FROM profiles LIMIT 5; -- should return nothing, not a full dump -- Check which tables still have no policy at all (run as service_role / owner) SELECT tablename, rowsecurity FROM pg_tables WHERE schemaname = 'public' AND rowsecurity = false; -- if this returns rows, those tables have no RLS
And if you want to be thorough once, audit grants too:
SELECT table_name, grantee, privilege_type FROM information_schema.role_table_grants WHERE grantee = 'anon' AND table_schema = 'public'; -- anything here needs a reason, or a REVOKE
If the first check returns zero for anon and the expected rows for the owner — and the second check has no unexpected table_name / anon / SELECT rows — you are in good shape for those tables. Put the check in a recurring place. It is easy to add a table next week without a policy and be back where you started. A simple rule that helps: every migration that creates a table must also enable RLS and create at least one policy in the same migration file. Make it a team habit, not a one-time cleanup.
What this does not fix
RLS is not a substitute for closing open API routes. A Route Handler that queries Supabase with theservice_role key bypasses RLS entirely — that key is the superuser by design. If you have a GET /api/waitlist that uses the service role and does not check auth, RLS onwaitlist does not help. The agent just calls your API instead of PostgREST. We cover that shape in the next post.
But get this one right first. In my experience, more real data leaves through a missing RLS policy than through any clever trick. The boring table you added last Tuesday with no policy is the seam. Go close it. It takes two minutes and it stays closed.
I keep coming back to how unglamorous this work feels compared to the stories about agent swarms. Nobody wants to write a blog post about adding a SQL policy. But honestly, idk — there is something grounding about it. The swarm gets hyped as this unstoppable AI thing, and then you look at what it actually gets and it is just rows from a table that never had a lock on it. The defense is not brilliant either. It is just a lock.
You can still ship this fix yourself. With GitHub connected, Zoltra can also prepare the repair as a pull request, leave it for your review, and verify the live result after deployment.
Next: Close your open API routes — why the endpoint that “just returns the waitlist” is an open door, and how one auth check fixes it.