Your Supabase app might be readable by anyone (RLS off)

Testing that it works never tests whether it’s private — check the red badge tonight.

7 min read

Argued into existence in the Writing Room7 messages · 1 mind changed
Your Supabase app might be readable by anyone (RLS off)

Okay so — you had Claude, or Cursor, or Lovable, or Bolt write you a Supabase backend last week. It worked. You shipped it. You moved on with your life.

Then you saw a headline about a Supabase database getting scraped, or you opened your own dashboard for an unrelated reason and every single table has a red badge on it that says "Unrestricted," and now it's 11pm and you're wondering: is my user table just sitting there for anyone to read right now?

I'm not going to make you wait for the answer. Some of your tables probably are. Not because you did anything careless — because of which editor typed the SQL.

Here's the thesis up front: "it works" and "it's private" are two completely different questions, and only one of them ever gets tested when you click through your own app. This piece is about the other one.

The badge is not decoration

Open your Supabase dashboard and click into Table Editor. Look at the top of any table. If you see a red pill that says "Unrestricted," that is Supabase telling you, in the most literal way it knows how, that Row Level Security is off on that table and the public API can read every row in it.

Row Level Security — RLS — is the thing that decides whether a request coming in through Supabase's REST API is allowed to see a given row. When it's off, the answer is always yes, to anyone, with nothing but the "anon" key that's already sitting in your app's frontend bundle in plain text. That key isn't the leak. It was never meant to be secret — it's supposed to be safe to publish, and it is, as long as RLS is doing its job on the other end. The leak is a table with RLS off standing behind it.

If that's all you needed tonight, skip to the Final Thought below — open your dashboard, check every table for the badge, done. Everything between here and there is proof, not a prerequisite.

Why some of your tables have the badge and some don't

Here's the part that actually explains what you're looking at, and it's the reason this isn't a "you made a mistake" story.

Supabase has two front doors into the same database, and they don't behave the same way:

  • A table you create by clicking "New table" in the Table Editor ships with the "Enable Row Level Security" checkbox already ticked. RLS on, no policies yet, which means: on, but closed. Nobody gets in until you write a rule.
  • A table created by running CREATE TABLE in the SQL Editor — or by an AI tool pasting a migration, the SQL script that actually builds your tables, into that same SQL Editor, which is exactly how Claude, Cursor, Lovable, and Bolt scaffold a Supabase backend — ships with RLS off. Wide open. No checkbox to miss, because there isn't one in that path at all.

That split is documented, not just my paragraph: Supabase's own Row Level Security guide describes it, and GitHub discussion #21747 is other developers hitting the same dashboard-versus-SQL gap, in public, long before this piece.

Same database, same product, two different defaults depending on which door you walked in. Your AI tool almost certainly walked in through the SQL Editor door, because that's the door that takes a block of SQL and just runs it. It's not being reckless. It's doing exactly what you asked — build this table — and the table it builds is, by default, public.

I checked this myself before writing a word of it, on a clean Postgres 16.13 cluster, reading both kinds of table back as a non-superuser role with only SELECT granted — the same shape of request Supabase's REST API makes with the anon key. Plain CREATE TABLE with no RLS statement handed that role every row, both emails, in the clear. The same table built with RLS enabled and zero policies gave it nothing back — not an error, an empty result — which is the part I want you to actually see for yourself in the next section, not just take from me.

Build the proof yourself

I'm not going to ask you to trust a paragraph on the internet about your own database. Here's the actual test — it takes a few minutes, not one, and it's worth doing once so you never have to take a scanner's word, or mine, for this again.

Step 1 — build a table the AI-tool way, in the SQL Editor:

create table public.profiles (
  id uuid primary key default gen_random_uuid(),
  email text,
  plan text
);

insert into public.profiles (email, plan) values
  ('sam@realcompany.com', 'pro'),
  ('ana@personalmail.com', 'free');

Step 2 — grab your project URL and anon key from Settings > API, and hit the table through the same public REST endpoint your frontend uses:

curl 'https://YOUR-PROJECT-REF.supabase.co/rest/v1/profiles?select=*' \
  -H "apikey: YOUR-ANON-KEY" \
  -H "Authorization: Bearer YOUR-ANON-KEY"

If that table went in through the SQL Editor with no RLS statement attached, here's what comes back — the anon key, the same one sitting in your compiled JS right now, handing a stranger both email addresses and both plans:

[
  { "id": "b1a2...", "email": "sam@realcompany.com", "plan": "pro" },
  { "id": "c3d4...", "email": "ana@personalmail.com", "plan": "free" }
]

Step 3 — now go into Table Editor and click "New table" instead, leave the RLS checkbox as-is, add the same two columns, and run the exact same curl command against that table. This time you get:

[]

An empty array. Not an error, not a 403 — Supabase doesn't even tell an anonymous requester the table exists in any meaningful way. That's RLS doing exactly what it's supposed to do: on, with zero policies written, so the honest answer to "can this anonymous request see any rows" is none. Same product, same key, two tables, two completely different answers, and now you've seen both with your own terminal instead of taking my word or a scanner's word for either one.

The fix, once you know which tables are open

Go to Authentication > Policies in your dashboard. Every table with the red "Unrestricted" badge shows up there, and for each one you have two honest options, not one:

  • Turn RLS on and write a policy that actually matches what the table should allow — "users can select their own rows," for example:
alter table public.profiles enable row level security;

create policy "users can read own profile"
on public.profiles for select
using (auth.uid() = id);

auth.uid() is the JWT claim PostgREST decodes off the incoming request — the id of whoever is currently signed in. It's the primitive behind every "match the row to the caller" policy you'll write from here on.

This example assumes your profiles table stores each row under the same id as the logged-in user — the normal pattern when a profiles row is created alongside a Supabase Auth signup. If your table keys users a different way, the column on the right side of that comparison changes, but the shape of the policy — match the row to the caller — stays the same.

  • Or, if the table genuinely has no public reason to be readable at all right now — a backend table your app only touches through server-side code with the service role key — just flip RLS on with no policies. That's the "empty array" state from step 3. Nothing gets in until you decide to let it, which is a perfectly good place to leave a table while you figure out the real rule.

What you should not do is leave it Unrestricted because writing the policy felt like the boring part before the fun part. It is the boring part. It's also the ten minutes that separates "I built an app" from "I built an app and an open database."

Why this matters

The AI tool that scaffolded your backend did not lie to you and it did not cut a corner. It ran the SQL you effectively asked for, through the door that happens to default open, and every table it touched inherited that default silently — no warning, no red flag in the chat transcript, nothing that would make you go check. The bug isn't in the code it wrote. It's the gap I opened with: testing that your app works will never accidentally test whether it's private, and nothing in the build flow makes you check the second one.

Final Thought

Don't read a scanner's verdict about your project. Open your own dashboard, go to Table Editor, and look at every table for that red "Unrestricted" badge — right now, tonight, before you close this tab. Whatever's flagged, walk into Authentication > Policies and give it a real rule, or lock it down empty until you write one. That's the whole fix, and you already know how to do it.

Your Supabase app might be readable by anyone (RLS off) | Vibecodes