Your Supabase Database Is Probably Public Right Now

Supabase ships your database to the browser on purpose. Row Level Security is the only thing standing between your users table and everyone. Here is how RLS works and how to write policies that hold.

CJ
CJ
·Blog

If you built an app with Lovable, Bolt, or Replit and it uses Supabase, open a new browser tab right now and paste this in the URL bar:

https://YOUR-PROJECT.supabase.co/rest/v1/profiles?select=*&apikey=YOUR-ANON-KEY

Both of those values are sitting in your published JavaScript. Anyone can read them in about four seconds using view-source.

If that URL returns your users table, your database is public. Not "technically exposed under certain conditions." Public. Anyone on the internet can read every row, and depending on your setup, write to it too.

I am not trying to scare you. This is the single most common problem I see in apps built with AI builders, and it happens for a boring reason: Supabase is designed this way, and the builder didn't finish the job.

Why your database is exposed by default

Traditional web apps keep the database behind a server. The browser talks to your server, your server talks to the database, and the database credentials never leave the building.

Supabase works differently. It puts a REST API directly in front of your Postgres database and hands your browser a key called the anon key. Your React app queries the database straight from the user's browser. No server in the middle.

That is genuinely useful. It's why you can build a working app in an afternoon. But it means the security model moved. There is no server left to check "is this person allowed to see this row?" That check has to happen inside the database itself.

The Postgres feature that does this is Row Level Security. RLS.

What RLS actually is

Row Level Security is a Postgres feature that attaches rules to a table. When any query touches that table, Postgres filters the rows based on who is asking.

Two things have to be true for it to protect you:

  1. RLS is enabled on the table
  2. There are policies that describe who can do what

Miss either one and you have a problem, but they fail in opposite directions.

If RLS is disabled, Postgres does no filtering. Every row is returned to anyone holding the anon key. This is the dangerous state, and it's the default for tables created outside the Supabase dashboard, which includes most tables that an AI builder generates through migrations.

If RLS is enabled with no policies, Postgres denies everything. Your app breaks loudly, you notice immediately, and you fix it. Annoying, but safe.

Enabled with a bad policy is the state that bites people, and I'll get to that.

How to check whether you are exposed

In the Supabase dashboard, go to Table Editor. Any table showing an "RLS disabled" or "Unrestricted" badge is readable by the public.

You can also ask Postgres directly. Open the SQL Editor and run:

select
  schemaname,
  tablename,
  rowsecurity as rls_enabled
from pg_tables
where schemaname = 'public'
order by rls_enabled, tablename;

Every row that comes back with rls_enabled = false is a table anyone can query. Take a screenshot of that list. It's your to-do list.

Turning RLS on without breaking your app

Enabling RLS is one line per table:

alter table profiles enable row level security;

The moment you run that, every query against profiles returns zero rows, including your own app's queries. That is correct behaviour. Postgres is now denying by default and waiting for you to describe the exceptions.

So do this in the right order: write the policies first, then enable RLS. Or enable it and immediately add policies in the same session, while you still remember which queries your app makes.

Writing a policy that actually works

Here is the pattern for a table where each row belongs to one user. Assume profiles has a user_id column of type uuid.

-- Users can read their own profile
create policy "read own profile"
on profiles
for select
to authenticated
using (auth.uid() = user_id);

-- Users can update their own profile
create policy "update own profile"
on profiles
for update
to authenticated
using (auth.uid() = user_id)
with check (auth.uid() = user_id);

-- Users can create a profile, but only for themselves
create policy "insert own profile"
on profiles
for insert
to authenticated
with check (auth.uid() = user_id);

A few things worth understanding rather than copying.

auth.uid() reads the user's ID out of the JWT that Supabase issued at login. It is not something the browser can forge, because the token is signed. This is the hinge the whole system turns on.

using controls which existing rows the operation can see or touch. with check controls what the row is allowed to look like after an insert or update. You need with check on updates too, otherwise a user can pass the using check on a row they own and then reassign user_id to someone else.

to authenticated limits the policy to logged-in users. Without it the policy also applies to the anon role, which is not what you want here.

Policies are permissive by default, which means they combine with OR. Two policies on the same table and operation means a row is visible if either matches. That is a common source of accidental exposure when you add a policy later and forget the one already there.

The policy that looks fine and protects nothing

This is what I see most often in AI-generated migrations:

create policy "enable read access for all users"
on profiles
for select
using (true);

RLS is enabled. There is a policy. The dashboard badge turns green. And the table is still completely public, because using (true) means "every row passes."

The model wrote this because it made your app work. You asked why data wasn't loading, it produced a policy that unblocked you, and it moved on. It is not lying to you and it is not being careless. It optimised for the thing you asked for, which was a working query.

That is the part worth internalising. An AI builder will reliably get you to "it works." It will not reliably get you to "it works and nobody else can read it," because you never asked, and nothing in the feedback loop punishes the difference. Your app looks identical either way.

The key that bypasses all of this

Supabase gives you a second key called service_role. It ignores RLS entirely, by design, so your backend jobs can do admin work.

It must never appear in client-side code. Not in a React component, not in a NEXT_PUBLIC_ environment variable, not in anything that ends up in the browser bundle.

Search your project for it before you deploy:

grep -rn "service_role\|SERVICE_ROLE" src/ .env* 2>/dev/null

If it turns up anywhere that gets sent to the browser, rotate the key in the Supabase dashboard immediately and move it to a server-side environment variable. A leaked service_role key is full read and write access to everything, and no policy will stop it.

A checklist you can run in ten minutes

  1. Run the pg_tables query above. Note every table with rls_enabled = false.
  2. For each one, decide who should read it and who should write it. Write that down in plain English first.
  3. Translate each sentence into a policy. One policy per operation, scoped to authenticated unless the data really is meant to be public.
  4. Grep for any policy using using (true) and confirm you meant it. Public blog posts, yes. Anything with a user_id column, almost certainly not.
  5. Enable RLS on the table.
  6. Log in as a test user and click through the app. Then log in as a second test user and confirm you cannot see the first one's data.

Step 6 is the one people skip, and it's the only one that actually proves anything. Two accounts, side by side. If account B can see account A's rows, your policy is wrong no matter how good it looks in SQL.

Frequently asked questions

What is RLS in Supabase? Row Level Security is a Postgres feature that filters which rows a query can see or modify, based on rules called policies. Supabase depends on it for authorization because your database is exposed directly to the browser, with no server in between to check permissions.

Does enabling RLS slow down my queries? The policy condition is evaluated per row, so it behaves like an extra WHERE clause. If your policy compares against an indexed column such as user_id, the cost is usually small. If your policy runs a subquery against another table, add an index on the column it joins on.

Do I need RLS if my app has a login screen? Yes. The login screen protects your user interface. It does nothing to protect the REST API, which sits at a public URL and accepts the anon key that ships in your JavaScript. Anyone can query it directly without ever loading your app.

What happens if I enable RLS and forget to write policies? Every query returns zero rows. Your app will break in an obvious way. This is the safe failure mode, which is exactly why it works like that.

Is the anon key a secret? No. It is meant to be public and there is no way to hide it. Its safety depends entirely on your RLS policies being correct.


If you want to know where you stand without working through this by hand, our free security scan checks a live app for exposed tables, missing policies, and leaked keys, and tells you which ones to fix first.

And if the deeper problem is that you can ship features but can't yet tell whether they're safe to put in front of real users, that gap is the whole reason VibeMastery exists.

Read Next

Stop guessing. Start shipping.

200+ students use VibeMastery to turn AI prototypes into production apps, with a system instead of luck.

Lifetime Access14 Hours of VideoPrivate Discord
Get VibeMastery — $169

One-time payment · Lifetime access

67%
Off