Skip to content
QLTech
9 min readBy QLTech

Supabase says RLS is disabled in public: what it means and how to fix it properly

The Supabase security warning that AI-built apps trigger most. What it actually exposes, how to check how bad it is right now, the policies your app needs, what the linter does not tell you, and how to keep it fixed.

  • Supabase
  • Security
  • Vibe coding
  • PostgreSQL

If you have opened the Supabase dashboard recently and found a red warning in the Security Advisor that reads "RLS Disabled in Public", this is what it means in plain terms. One or more of your tables lives in the public schema, the schema Supabase exposes through its REST and GraphQL APIs, and that table has row level security switched off. Any request that carries your project's anon key can read every row in it, and unless you have removed the default grants, write to it as well.

Your anon key is not a secret. It is shipped in the JavaScript bundle of your app so that the browser can talk to Supabase. Every visitor has it. Anyone who opens the network tab, copies the key and sends a request from a terminal gets the same access your app has, minus the parts of your app that politely hid buttons from them. If the table holds user profiles, orders, messages or uploaded documents, that data is public in every sense that matters.

This shows up constantly in apps built with Lovable, Bolt, v0, Cursor and Claude Code, and it is not really the tools' fault. When a table is created with plain SQL, Postgres does not enable row level security on it. The AI scaffolds a dozen tables in a minute, wires the front end to them, and the demo works beautifully, because there is one user and that user is you. Nobody prompted for "and only let people see their own rows", so nobody got it. The Supabase linter is doing you a favour by shouting.

Check how exposed you are right now

Start by listing the tables in public that have RLS off. Run this in the SQL editor:

select c.relname as table_name
from pg_class c
join pg_namespace n on n.oid = c.relnamespace
where n.nspname = 'public'
  and c.relkind = 'r'
  and c.relrowsecurity = false
order by 1;

Every name that comes back is a table the API will serve without any row filtering. Now prove it from outside your app, using nothing but the anon key, the way an attacker would. Replace the project reference, the table and the key:

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

If the response is an empty array, RLS is on and no policy grants anonymous reads, which is what you want for private data. If rows come back, that is exactly what any stranger sees. Try the same request against each table from the list, and try a POST with a small JSON body too, because write access with no session is the version of this problem that fills your database with junk or deletes it.

Turn RLS on, then write the policies your app actually needs

Enabling row level security is one statement per table:

alter table public.profiles enable row level security;

Do this and your app will almost certainly break, because RLS with no policies denies everything. Every query returns an empty result, the front end shows blank screens, and this is the moment people prompt the AI with "the app stopped working after I fixed security" and it helpfully turns RLS back off. Do not let it. Empty results are correct; you now add back only the access your app genuinely needs.

The patterns below cover most apps. Adjust the table and column names.

Owner-only rows. Users see and change their own records and nothing else.

create policy "profiles: owner can read"
  on public.profiles for select
  using (auth.uid() = user_id);

create policy "profiles: owner can insert"
  on public.profiles for insert
  with check (auth.uid() = user_id);

create policy "profiles: owner can update"
  on public.profiles for update
  using (auth.uid() = user_id)
  with check (auth.uid() = user_id);

Public read, owner write. Blog posts, listings, anything meant to be visible to everyone.

create policy "posts: anyone can read published"
  on public.posts for select
  using (published = true);

create policy "posts: author can write"
  on public.posts for all
  using (auth.uid() = author_id)
  with check (auth.uid() = author_id);

Team or organisation membership. Rows belong to an organisation and any member of it may read them.

create policy "documents: members can read"
  on public.documents for select
  using (
    exists (
      select 1 from public.memberships m
      where m.organisation_id = documents.organisation_id
        and m.user_id = auth.uid()
    )
  );

Admins. Keep the role in a table you control rather than trusting a value the client can set on its own profile row.

create policy "orders: admins can read all"
  on public.orders for select
  using (
    exists (
      select 1 from public.admin_users a
      where a.user_id = auth.uid()
    )
  );

A few rules of thumb. using decides which existing rows a statement may see or touch; with check decides which new or changed rows are acceptable. Write both for update policies or you leave a gap. Name policies so you can tell what they do six months later. And keep policies narrow: a policy using (true) on a private table is RLS in name only.

Things the linter does not tell you

The Security Advisor catches the obvious case. These are the ones we find by hand on almost every rescue.

  • The service role key in client code. This key bypasses RLS completely. It belongs on a server, in an edge function or in an environment variable that never reaches the browser. If it is in your front-end code, in a public repository or in a Lovable project setting that is compiled into the bundle, rotate it today and move the code that needs it server-side.
  • Views and functions that bypass RLS. A view runs with the privileges of its owner unless it is created with security_invoker, and a function declared security definer runs as the user who defined it, usually the owner of everything. AI tools reach for both when a query gets awkward. Audit every view and function in public and ask whether it quietly sidesteps your policies.
  • Storage bucket policies. Uploaded files are governed by their own policies on storage.objects, not by your table policies. A public bucket with predictable file names is a leak, and a private bucket with a permissive policy is the same leak with extra steps.
  • Exposed schemas. The API setting that lists which schemas are exposed defaults to public. If someone added another schema to that list to make a query work, every table in it has the same problem.
  • Grants for anon versus authenticated. RLS decides which rows; grants decide which operations. By default both roles can select, insert, update and delete on new tables. For a table that should never be written from the browser, revoke insert, update and delete from anon and authenticated outright, and do the writes from a function you control.
  • Realtime. If you broadcast table changes over realtime, the subscription respects RLS for postgres_changes, but only if RLS is on. A table with RLS off streams every change to every subscriber.

Test it like an attacker would

Fixing policies without testing them is how "secure" apps still leak. After every schema change, run these five checks:

  1. Second account. Sign up a fresh user, log in, and try to read and edit rows that belong to your main account through the app and through the REST API.
  2. No session at all. Repeat the requests with only the anon key and no bearer token for a user. Anything private must return empty or an error.
  3. Direct REST and RPC. Call your tables and any rpc functions from curl, not through your UI. The UI hides buttons; the API does not.
  4. Writes, not just reads. Attempt inserts, updates and deletes as the second account and as anonymous. Check that with check clauses reject rows with someone else's user_id.
  5. Storage. Try to list and download objects from each bucket as the second account and as anonymous.

Keep the curl commands in a file in the repository so the checks take two minutes, not an afternoon, and so the next person can run them.

Keep it fixed

Security that lives only in the dashboard drifts. Put every enable row level security and every policy into a migration file under version control, so the schema of production is something you can read, review and rebuild. Add a small policy test to your CI pipeline: a script that signs in as two users and asserts that each sees only their own rows will catch most regressions before they ship.

Stop letting the AI tool run raw SQL against production. Give it a development project, let it propose migrations, and apply them to production yourself after reading them. Keep preview and production as separate Supabase projects with separate keys; the alternative is a preview branch that quietly edits live data.

Finally, re-run the Security Advisor after each deploy. It is free, it is fast, and its warnings are usually right.

Where QLTech fits

We take security seriously and check it on every project, and Supabase access rules are the first thing we look at when we take over an AI-built app. If the warning above is sitting in your dashboard and you would rather have it fixed properly than prompted around, our AI app rescue starts with a fixed-price audit that covers exactly this. For the wider list of things to check before an AI-built app goes live, read the vibe-coding security checklist.