Skip to content
Back to field notes
SupabaseJune 6, 20266 min read

How to check if your Supabase tables are publicly readable

Before you share the URL, a few minutes tells you which Supabase tables answer an anonymous caller holding your public anon key. This note walks the exact self-check, table by table.

SupabaseRLSPostgREST

The supabase public table access check, in plain steps

Before the URL spreads, the question worth answering is simple: which of your Supabase tables will hand data to a stranger who has nothing but your public anon key. That key ships in the client bundle of every deployed app, so the stranger is any visitor who opens DevTools, plus any crawler or script that loads the page. You do not have to guess. You can ask each table directly, from the outside, in about a minute per table.

Here is the whole self-check, run against your own project only:

This is not an attack. You are sending the exact request your own app already sends, as the role an outside visitor would use. The point is to see the answer before someone else does.

  • Find your project URL and anon key. Open the deployed app in a clean browser, open DevTools, and watch the Network tab for requests going to your-project.supabase.co/rest/v1/. The request host is your project URL, and the apikey header on those requests is the anon key. The key is also sitting in the loaded JavaScript; searching the bundle for supabase.co will find it.
  • Pick one table and send a GET to its PostgREST endpoint as the anonymous caller. The path is /rest/v1/your_table?select=*, and the anon key goes in the apikey header (Supabase clients also send it as a Bearer token, but the apikey header alone is enough to test). You can do this from the browser console, from a small fetch, or from any HTTP tool.
  • Read the response. That single response tells you whether the table is open.

What the response means

The body that comes back is the whole signal, and there are three outcomes worth separating.

Two cautions on reading this. An empty array can also just mean the table is genuinely empty, so confirm against a table you know holds data. And a populated array is only a problem for tables that should not be public; a table of public blog posts answering an anonymous GET is working as intended. The judgement is yours — the check only tells you what is reachable.

  • A populated array of rows. The table is readable by anyone with the public anon key. In Supabase terms that means Row Level Security is off on this table, or it has a permissive policy that lets every row through. Either way, the data in that table is reachable from the open internet with no login.
  • An empty array. The endpoint answered, but no rows came back. With RLS enabled and no matching policy, an empty array is the expected deny, not a bug. It means the API accepted the anonymous request and correctly returned nothing.
  • A permission error. The request was refused outright. That is also a healthy result for a private table.

Check every table, and check writes too

RLS is per table. One table returning a clean deny tells you nothing about the next one, because each table carries its own rules, and a table added in a later prompt or migration starts with no row-level rules at all. So the GET above is a per-table check, not a one-time check. Go down the list of tables your app created — profiles, payments, submissions, settings, admin, whatever exists — and send the same request to each.

Reads are only half of it. A table can be readable, writable, or both, and a read-only check misses the writable case entirely, which is usually the worse one.

If you would rather start from a list than probe blind, open the Database linter under Advisors in the Supabase dashboard. It flags tables exposed through the REST API with RLS disabled, which gives you the candidates to confirm from the outside.

  • To test writes, send a POST to /rest/v1/your_table with a small test payload and the anon key, against your own project. A response that creates a row means the anonymous caller can insert into that table.
  • Send a PATCH against a row id the same way to check updates. A successful PATCH means anyone can change existing records, not just read them.
  • A write that succeeds with only the anon key is a clear signal the table is open for writing. An anonymous caller adding or altering rows is generally more damaging than one merely reading them.

Why AI-built apps land here

This is not a flaw you wrote on purpose. RLS is opt-in per table in Supabase. A freshly created table has no row-level rules, and the auto-generated PostgREST API will serve it to the anonymous role until you say otherwise. Nothing in the create-table step forces a policy first.

That default collides with how generated builds come together. A prompt like store user submissions in Supabase produces a working table, a working client call, and UI that reads and writes through the anon key — and it all works on the first try. It works precisely because nothing is standing in front of the table yet. The feature functioning end to end is exactly what hides the gap: the app looks finished, so the per-table review where someone decides who may read each table never happens. Then fast deploy turns the preview into a public URL before anyone has measured the anon key's real reach.

The deeper mechanics — the anon role, deny-all, writing policies — are covered in the Lovable and Supabase RLS defaults note; this one stays on the self-check. The same per-table reasoning applies to Supabase Storage, where buckets have their own public-or-private setting; see the Supabase storage bucket public access note for that surface.

Fix direction: enable RLS, deny all, then open explicit paths

For any table that returned rows it should not have, the fix is per table and the safe place to land is closed.

After you enable RLS and write policies, re-run the same GET and POST probes from the outside. The reads that should be private should now return an empty array or a permission error, and the writes that should be blocked should fail. Verifying again is part of the fix, not an optional extra — a policy you did not test is a guess.

  • Enable RLS on every table holding user, account, payment, or admin data. Enabling RLS with no policy denies all access. The table goes dark, which is correct — you then open only the paths you intend. Starting from deny-all and adding policies is far safer than leaving a table open while you decide.
  • Write one policy per operation you actually want — select, insert, update, delete — rather than a single broad rule that lets everything through, which puts you back where you started.
  • Scope reads and writes to the row owner where that applies, so a logged-in user touches their own rows and no one else's.
  • Never put the service_role key anywhere it can ship to the client. It bypasses RLS entirely, so a service_role key in the bundle hands every table to every visitor regardless of policies. If one ever shipped, rotate it and treat it as leaked.

Where VibeCodeGuard fits, and what it does not prove

VibeCodeGuard's Launch Check probes the public Supabase REST endpoint with the anon key, the same outside view an anonymous visitor has, and reports tables that return data without authentication. It flags reachability — which tables answer the anonymous caller — with severity and fix direction, so an open table shows up in your own check rather than someone else's.

What it does not do is author your policies. Deciding which rows each role should see, and writing correct per-table RLS in Supabase, is your work, and it should pair with a manual look at your auth and data model. Launch Check is not a penetration test, a code review, or a guarantee that the app is secure.

A clean public-surface scan means the obvious anon-key exposures are handled — not that every table is correctly scoped. The scan sees what is reachable from outside with the anon key; it does not read your policy logic or confirm that the policies you wrote match your intent. Treat a flagged table as a launch blocker and a clean result as one signal, not proof. Next step: list your tables, send the GET and POST probes to each as the anon caller, then run a focused Launch Check against the public URL before you share it.

> launch check

Scan the public surface before launch.

Get severity, evidence, and practical fix guidance for the checks VibeCodeGuard can run from the outside.