#1 of 10critical

Database tables anyone can read

A Supabase table is readable by anyone when Row Level Security (RLS) is off, or when a policy allows every visitor, because your app's anon key is public by design. In our scan of public GitHub repos, 42% of Bolt apps and 15% of Lovable apps shipped at least one table like this. The fix: enable RLS on every table in the public schema and write policies that match rows to auth.uid().

Where it shows up: Supabase (Lovable, Bolt, v0 and most AI builders use it by default)

Last updated

Are you affected? The 60-second check

In the Supabase dashboard, open Advisors → Security Advisor. Every "RLS disabled in public" warning (lint 0013) is a table open to the internet.

RLS can be on and still leak through a loose policy, so test what a stranger sees. Run this against your own project, using only the anon key from your site's code:

Terminal
curl "https://YOUR-PROJECT.supabase.co/rest/v1/YOUR_TABLE?select=*&limit=1" \ -H "apikey: YOUR_ANON_KEY"

An empty list ([]) means you're blocked or the table is empty. Rows coming back from a private table mean anyone can read them. Or let Shippable test every table your app uses in one go: npx shippable-dev scan localhost:5173.

What it is

Supabase apps talk to the database straight from the browser. Two values make that work, and both ship in your site's code: your project URL and the anon key (also called the publishable key). Neither is a password. The anon key identifies your project; it doesn't protect it.

Row Level Security (RLS) is what protects it. RLS is a Postgres feature: rules attached to a table that decide, row by row, what each request may read, insert, update or delete. With RLS off, every row is available to anyone holding the anon key, which means everyone.

AI builders get this wrong in a few predictable ways. Tables created with SQL or migrations start with RLS off, and Bolt and Lovable create tables through migrations, so every new table is a fresh chance to forget. A SELECT policy of USING (true), often added to make a permission error go away, is the same as RLS off for reads. A policy of auth.role() = 'authenticated' lets any signed-up user read every other user's rows, and sign-up is usually open to anyone. And an "owner can update own row" policy on a profiles table that also holds role or plan lets users promote themselves.

The app works perfectly in testing either way, which is why nobody notices.

How often we see it

We ran Shippable's static checks on about 1,460 public GitHub repos built with five AI tools (October 2026). Each figure is the share of that builder's repos where the check fired at least once. A dash means it wasn't among that builder's most common findings. Full method and breakdown: Which AI builder ships the safest apps?

FindingBoltLovableCursorReplitv0
Database tables open to the public42.4%14.6%–––
Users can edit their own permissions33.9%10.2%–––
Anyone (signed out) can write20%6.8%–––
View bypasses Row Level Security12.5%––––
Injectable PostgREST filter14.6%7.8%–2.8%–

What an attacker sees

Your site's code contains your database address and public key. With those two, anyone can ask your database for every row of an open table, including other users' emails, messages or orders, without ever logging in. No hacking skills are involved; it's a normal API request. This isn't hypothetical: CVE-2025-48757 documented exactly this pattern across apps built with Lovable in 2025, with 170 of the 1,645 apps tested exposing user data.

How to fix it

Paste this into your AI tool:

Paste the prompt below into your vibecoding tool

Security fix: my Supabase tables must not be readable by anonymous users. Enable Row Level Security on every table in the public schema and add policies so each user can only read and write their own rows (match auth.uid() to the owner column). Do not allow anonymous or cross-user access. Tell me which tables are meant to be public.

Supabase SQL editor
-- 1. Turn on Row Level Security for the exposed table: -- With no policies yet, this blocks all access through the API. ALTER TABLE public.profiles ENABLE ROW LEVEL SECURITY; -- 2. Add a policy that grants ONLY the access you intend. Example: -- (assumes the table has a user_id column identifying the owner) CREATE POLICY "profiles_owner_can_read" ON public.profiles FOR SELECT TO authenticated USING ((select auth.uid()) = user_id); CREATE POLICY "profiles_owner_can_update" ON public.profiles FOR UPDATE TO authenticated USING ((select auth.uid()) = user_id) WITH CHECK ((select auth.uid()) = user_id); -- 3. If the table holds role, plan or credits, users must not edit them. -- Revoke UPDATE on the table, then grant it back only on safe columns: REVOKE UPDATE ON public.profiles FROM authenticated; GRANT UPDATE (display_name, avatar_url) ON public.profiles TO authenticated;

Or by hand:

  1. In Supabase, open Table Editor and check each table for the RLS badge. Enable it wherever it's off.
  2. In Authentication → Policies, make sure no policy uses `true` for reads on private data.
  3. Tables that really are public, like a product catalogue, can keep a read-only USING (true) policy. Make that a deliberate choice, and never give such a table INSERT or UPDATE for anon.
  4. Re-scan to confirm every table now refuses anonymous reads.

Lovable

Paste the prompt above into Lovable, then open the Supabase dashboard and confirm every table shows RLS enabled. Run the curl check too: a policy can be on and still be too loose.

Bolt

Bolt writes SQL migrations in supabase/migrations/. Search them for CREATE TABLE and make sure each one is followed by ENABLE ROW LEVEL SECURITY. Search for USING (true) and TO anon, and remove any you didn't mean to add. Then apply the migrations again.

v0, Cursor and hand-written apps

The same SQL applies. If you only call Supabase from server code with the service_role key, RLS isn't protecting those calls at all: keep that key server-only (see leak #2) and still enable RLS so the anon key can't reach the tables.

Mistakes that look fixed but aren't

  • RLS is enabled, but a leftover USING (true) policy still allows reads.
  • Reads are locked, but INSERT or UPDATE is still open to anon.
  • auth.role() = 'authenticated' is used as if it meant "the owner". It means "anyone with an account".
  • A view or a SECURITY DEFINER function returns the same rows while skipping RLS (see leak #6).
  • The owner can update their own row, and that row holds role, plan or credits.

Check your app

Shippable finds your Supabase address and public key in your site's code, then asks each table for a few rows as an anonymous visitor. If rows come back, the table is open, and you get the table and column names as proof. It never shows the values and never writes anything.

Scan your dev server before you push. No account needed:

Terminal
npx shippable-dev scan localhost:5173

Or scan your live app in the browser:

FAQ

Is the Supabase anon key safe to expose?

Yes, it's designed to be public. It's only safe if Row Level Security protects every table, view, function and storage bucket, because anyone can use the key to query your database.

Does Supabase enable RLS by default?

Not for tables created with SQL or migrations: those start with RLS off. The dashboard's Table Editor turns it on for new tables, but AI builders mostly create tables through SQL, so check every table.

What happens if RLS is enabled with no policies?

All access through the API is denied for anonymous and signed-in users. That's safe, but your app stops working until you add policies for the access you intend.

Does the service_role key respect RLS?

No. It bypasses RLS completely, so it must never appear in browser code or in a VITE_ or NEXT_PUBLIC_ variable.

How do I know if someone already read my data?

Check the API logs in the Supabase dashboard for requests to the exposed table that didn't come from your app's normal traffic. If the table held personal data, treat it as exposed and follow your local breach-notification rules.

Related questions