Is your Supabase data really private?

We rebuilt Supabase’s roles on a local Postgres, watched a stranger read and rewrite a table, then locked it. Here is what changed, and how to check your own app.

A closed metal door with a lock
Photo by Chelaxy Designs on Unsplashdithered by Cyborb

Supabase row level security (RLS) is a Postgres feature that checks every row against rules you write, called policies, before anyone can read or change it. It is what keeps a Supabase app’s data private, because the app’s public key ships to every visitor’s browser. On a table without RLS, anyone who copies that key can read, edit and delete every row.

We proved it on a local copy of Supabase’s setup. A stranger holding only the public key read two users’ phone numbers and renamed them both, and one command plus three policies shut that down. This guide shows the real output, then how to check every table in your own project, including apps built with Lovable or Bolt.

The short version
  • The anon or publishable key is meant to be public. Row level security is what makes that safe.
  • A table without RLS in an exposed schema is open to anyone with your project URL and key, for reading, editing and deleting.
  • Turning RLS on blocks everything. Policies then let each person reach only their own rows.
  • The secret or service_role key skips RLS entirely, so it belongs on a server, never in the app.
  • Check every table with the Security Advisor, one SQL query, or a request made with your public key. Lovable and Bolt have built-in scans too.

What is row level security?

Without RLS, Postgres checks only whether a role may use a table at all. With RLS, it also checks each row. A policy is a condition such as “the row belongs to the signed-in user”, and Supabase’s auth.uid() function returns that user’s ID for exactly this job.

RLS works alongside your login, not instead of it. Login proves who someone is; policies decide what they may touch. Our guide to adding login to your app covers the first half.

Why is my Supabase anon key public, and is that OK?

Yes, it is designed to be public, and it is safe only when RLS is on for every table the API exposes. The key just identifies your project. Supabase maps each request to a Postgres role, anon for visitors and authenticated for signed-in users, and your policies decide what that role may reach.

Checked on September 28, 2026, from Supabase’s API keys docs:

KeyLooks likeWhere it may liveRow level security
Publishable keysb_publishable_...Anywhere: web pages, mobile apps, source codeApplies
Legacy anon keyA long JWTSame as the publishable keyApplies
Secret keysb_secret_...Servers and backend functions onlySkipped: it runs as service_role, which bypasses RLS
Legacy service_role keyA long JWTServers onlySkipped

Two details from the same page are worth knowing. A secret key refuses to work from a browser: Supabase checks the User-Agent header and answers 401 Unauthorized. And Supabase says it is deprecating the legacy anon and service_role keys by the end of 2026. If a secret key ever reached your frontend, rotate it; our guide to keeping API keys safe covers how.

We tested it: an open table, then a locked one

We rebuilt the parts of a Supabase project that RLS depends on, in plain PostgreSQL 16.13. That meant the anon, authenticated and service_role roles, the default grants that hand every new table in public to those roles, and auth.uid(), all copied from Supabase’s own source. Each request ran the way Supabase’s Data API runs one: in a transaction, as the right role, with the user’s token details set.

The table is the kind an AI builder writes in a SQL migration, with two users, Ada and Bob:

SQL
create table public.profiles (
  id uuid primary key references auth.users (id),
  full_name text not null,
  phone text
);

First, a stranger with nothing but the public key, before RLS:

RLS off, role anon: select full_name, phone from public.profiles
  full_name   |      phone
--------------+------------------
 Ada Lovelace | +44 20 7946 0018
 Bob Marley   | +1 555 0100
(2 rows)

The same stranger then ran update public.profiles set full_name = 'hacked', and 2 rows changed. That is the whole attack: no password and no exploit, just the key from your site’s JavaScript.

Then one command and three policies:

SQL
alter table public.profiles enable row level security;

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

create policy "Users can create their own profile"
  on public.profiles for insert
  to authenticated
  with check ((select auth.uid()) = id);

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

Here is every request we ran, at each stage:

RequestRLS offRLS on, no policiesRLS on, three policies
A stranger reads the tableBoth rowsNothingNothing
The stranger renames everyone2 rows changed0 changed0 changed
Ada, signed in, reads the tableBoth rowsNothingHer own row
Ada renames everyoneNot runNot run1 row changed: her own

With the policies in place, Ada could not create a profile under another user’s ID, or move her own row onto one. Both attempts failed with new row violates row-level security policy for table "profiles".

Notice the middle column. RLS with no policies blocks everything, so your app will look broken, and that is the moment people switch RLS off again. Add policies instead. Our guide to choosing a database ran a shorter version of this test; this one adds the stranger’s view, writes and the mistakes below.

We also ran Supabase’s own Security Advisor checks against the test database with the Supabase CLI 2.84.2. With RLS off, they reported an ERROR, rls_disabled_in_public, for public.profiles. After the fixes, the only finding left was an INFO note about a newer table with RLS on and no policy yet.

How do I check every table in my Supabase project?

Run three checks, in this order: the Security Advisor, one SQL query, and a request made with nothing but your public key. Only test projects you own.

  1. Open the Security Advisor

    In your project’s dashboard, open Security Advisor and treat every ERROR as urgent. Three matter most for RLS. rls_disabled_in_public is a table anyone with your project URL can read, edit and delete. security_definer_view is a view that skips RLS. rls_references_user_metadata is a policy that trusts data users can edit. From a terminal, supabase db advisors runs the same checks.

  2. List every table and its policies

    Paste this into the SQL editor. Any table with rls_on set to f (false) is open to the public key.

    SQL
    select c.relname as table_name,
           c.relrowsecurity as rls_on,
           count(p.polname) as policies
    from pg_class c
    join pg_namespace n on n.oid = c.relnamespace
    left join pg_policy p on p.polrelid = c.oid
    where n.nspname = 'public' and c.relkind in ('r', 'p')
    group by c.relname, c.relrowsecurity
    order by rls_on, table_name;

    On our test database, before the fix, it printed profiles | f | 0.

  3. Try it as a stranger

    Copy your project URL and publishable key from the dashboard, then ask for a table the way anyone could. This is the request format from Supabase’s docs; we did not run it, because it needs a real project.

    Terminal
    curl 'https://<PROJECT_REF>.supabase.co/rest/v1/profiles?select=*' \
      -H "apikey: <PUBLISHABLE_KEY>"

    For a private table, an empty list, [], is what you want. Real rows mean anyone on the internet can read them.

How do I check the security rules on a Lovable or Bolt app?

Use each builder’s own scan first, then confirm with the three checks above. The scans help, and neither claims to catch everything.

  • Lovable runs a Quick scan automatically when you open the publish dialog, covering database access rules among other things, and offers a manual Deep scan. Its docs say these tools cannot guarantee complete security.

  • Bolt has a Security Audit under the database icon, then Security. It flags a missing RLS policy or a permission that is too open, and offers an Ask Bolt to fix button.

After any automatic fix, run the checks again, because an AI-written policy can be wrong too. Missing RLS is behind two of the best-documented leaks from AI-built apps, both listed in our log of AI agent incidents.

Five RLS mistakes that still leak data

Turning RLS on is step one. These are the ways data still gets out, four of them reproduced in our test.

  1. An always-true policy on private data. using (true) for signed-in users looks safe, but anyone can sign up. In our test, Ada could read Bob’s phone number again. The advisor in the CLI we ran did not flag it: its always-true check (0024) skips read policies on purpose, since using (true) is the normal way to make a table readable by everyone. Only reading your policies catches this one.

  2. A view over a protected table. Views run with their creator’s permissions unless told otherwise, so they skip RLS. Our stranger read both profiles through a view with RLS on, and got nothing after alter view public.profile_directory set (security_invoker = true). The advisor flagged it as an ERROR.

  3. The secret key in the app. The service_role role bypasses RLS. In our test it read every row with all policies in place.

  4. Policies that trust user metadata. Signed-in users can edit their own raw_user_meta_data, so a policy that checks a role stored there can be talked around. Keep roles in a table users cannot write.

  5. New tables born open. Tables made in the Table Editor get RLS automatically; tables created with SQL, including migrations an AI tool writes, do not. Supabase’s docs include an event trigger that turns RLS on for every new table in public. In our test, the next table created had RLS on from the start.

Supabase also says it is changing the platform default so new tables are no longer granted to the API roles automatically, making exposure opt-in. Until your project has that, assume every new table is public.

One more place the secret key hides: an AI agent connected to your database through MCP. A demo in 2025 showed an agent using Supabase’s MCP server with the service_role key leaking private tokens; see MCP security risks. For the other common holes in AI-written apps, see securing AI-generated code.

FAQ

Is it safe to expose the Supabase anon key?

Yes, if row level security is on for every table the API exposes and your policies are right. The key identifies your project; the policies decide what each request can see.

Is my Supabase database public?

Any table in an exposed schema without RLS is. Run the Security Advisor or the SQL query above. A table in public with RLS off can be read and changed by anyone with your public key.

What happens if RLS is on but there are no policies?

Nothing gets through the API. Reads come back empty and writes change nothing, as our test showed. Add a policy for each action you want to allow.

Does row level security slow down my app?

It adds a check to every query. Supabase recommends wrapping auth.uid() in a select, as in the policies above, so it runs once per query, and indexing every column your policies filter on.

Does the service role key bypass RLS?

Yes. Secret and service_role keys run as the service_role role, which bypasses every policy. Use them only on a server.

Key takeaways
  • The public key is fine to expose; a table without RLS is not.
  • RLS on with no policies blocks everything. Add a policy per action instead of switching RLS off.
  • Keep the secret key on servers, and out of AI agents that do not need it.
  • Check with the Security Advisor, one SQL query and a request as a stranger, after every change.

Read next: how to keep API keys safe, and the AI agent incidents that started with an open table.

Sources
  1. Row Level Security, Supabase Docs, accessed September 2026
  2. API keys, Supabase Docs, accessed September 2026
  3. Securing your data, Supabase Docs, accessed September 2026
  4. Securing your API, Supabase Docs, accessed September 2026
  5. Tables and data, Supabase Docs, accessed September 2026
  6. Event triggers, Supabase Docs, accessed September 2026
  7. Advisors, Supabase Docs, accessed September 2026
  8. Build an API route in less than 2 minutes, Supabase Docs, accessed September 2026
  9. Initial schema: roles and default grants, supabase/postgres on GitHub
  10. Auth helper functions: auth.uid() and auth.role(), supabase/auth on GitHub
  11. Transactions, PostgREST documentation
  12. Supabase MCP security: how prompt injection leaked private tables, General Analysis, July 2025
  13. Security overview, Lovable Documentation, accessed September 2026
  14. Database: security settings, Bolt, accessed September 2026
cyborb.ai

Stop reading about it. Build it.

Describe what you want in plain words. Cyborb plans the work, writes and runs the code, makes the assets, and puts the result online.

Download Cyborb

Free to start. No card required.