A Supabase security checklist comes down to four things: switch on Row Level Security for every table, write at least one policy for each, keep the service_role key out of anything a browser downloads, and check your storage buckets separately because they do not follow your table rules. Supabase publishes the tables in your exposed schemas — public, by default — as their own web addresses, and your app works exactly the same whether those four things are done or not. Nothing breaks. Nothing warns you. The difference shows up when a stranger types your project URL into a terminal.
That is not hypothetical. In early 2025 a researcher scanned 1,645 apps built with the AI builder Lovable, which uses Supabase underneath, and found 170 — about one in ten — with databases anyone could read or write. It became CVE-2025-48757, rated 9.3 out of 10, and it was filed as an insufficient row-level-security policy: some tables had RLS switched off, others had policies that did not match the app’s own rules.
1. Turn on Row Level Security for every table
Row Level Security, usually shortened to RLS, is the switch that decides whether the database checks who is asking before handing over a row. With it off, a table published through the Data API is open to anyone holding your project URL and the key that ships in your front end — reads, inserts, updates and deletes alike. Supabase’s own docs put it plainly: tables exposed through the Data API without RLS can be reached by any role with matching grants.
Whether it starts on depends on how the table was made. Tables you create by clicking around the dashboard get RLS enabled, because the box is ticked for you. Tables created by running SQL — what an AI assistant often does when it builds your database, and what a migration does when you deploy — do not, unless the SQL says so. Treat that as a rule of thumb rather than a guarantee, and check rather than assume.
Find them in thirty seconds. Open the Security Advisor in your Supabase dashboard, under Database or Advisors depending on your version. Its “RLS disabled in public” check lists the tables that are exposed through the Data API with RLS off. Fix each with one line in the SQL editor:
alter table public.your_table_name enable row level security;
2. A policy is one sentence about who may see which rows
Turning RLS on locks the door but hands out no keys. Supabase’s docs are explicit: once RLS is enabled, no data is accessible through the API with your publishable key until you create policies. That is the safe direction to fail, and why your app may go blank right after step one.
A policy is the key. It says, in effect, “someone may read a row in this table when the user_id column on that row matches the person asking.” Two things catch people out. Policies are written per action, so reading, inserting, updating and deleting are four separate permissions: one that lets people read their own orders does nothing to stop them deleting someone else’s. And a policy whose condition is simply true leaves that action as wide open as no RLS at all — where you land after asking an assistant to make an error go away.
3. The service_role key ignores every policy you just wrote
The publishable key (newer projects show one starting with sb_publishable_; older ones call it the anon key) is built to sit in front-end code where everyone can see it. Supabase calls it “safe to expose online” for one reason: it reaches only what your policies allow.
The secret key (sb_secret_, or the older service_role key) is the opposite. It works through a database role carrying the BYPASSRLS attribute, so it skips every policy you have. Not some. All. Supabase’s wording: “never put one in a browser, a shipped application, or source control.”
Check it now. Search your project for service_role and sb_secret, and confirm every hit is server-side: an API route, an Edge Function, a backend file. Never a component, and never behind VITE_ or NEXT_PUBLIC_, since both prefixes mean the value is compiled into the JavaScript every visitor downloads. Then load your live site and search the page source and the JavaScript bundles it pulls in for a distinctive slice from the middle of the key — not the first few characters, which every key of that type shares. If it was ever exposed, rotate it now.
4. Storage buckets are a second permission system
If people upload anything — profile photos, invoices, scanned documents — those files live in Supabase Storage, which has its own permissions that your table policies do not touch. It is easy to lock down every table and forget this exists.
New buckets are private by default, but flipping one to public is the fastest way to make images appear on a page, so it happens. Public means retrieving and serving files bypasses access control: anyone with the address opens the file, with no login, for as long as that file and that public bucket exist. Uploading, deleting, moving and copying are still checked, which is why a public bucket can feel protected while every file in it is readable.
Any bucket holding something one customer should not see about another belongs private, with your app handing out temporary links: the method that makes those takes an expiry in seconds, while the public-URL method returns a permanent address that is built from the file’s path and asks nobody for credentials. Getting that backwards is how an invoice becomes readable by everyone.
5. Test it, do not assume it
Test the tables as a stranger. With your publishable key, project URL and a real table name:
curl "https://yourproject.supabase.co/rest/v1/profiles?select=*&limit=5" -H "apikey: YOUR_PUBLISHABLE_KEY" -H "Authorization: Bearer YOUR_PUBLISHABLE_KEY"
Rows coming back from a table that is not meant to be public mean anyone on the internet can read it. An empty array is the result you want — but only once you know the table actually holds rows, because an empty table and a locked one look identical here, and an error message is not a pass either. Repeat for every table holding personal data.
Test the policies as a customer. Make two accounts, sign in as the first, and change the id in the URL to point at a record belonging to the second. If you can read it, so can any customer.
Test the files. Copy a file URL out of your app and open it in a private window where you are logged out.
Those cover what is specific to Supabase. The rest of what tends to be missing days before launch — account deletion, spend limits, rate limiting — is in the pre-launch checklist, plus a shorter stack-specific list in is your Lovable app secure.
npx launchprep reads your repository for part of this — a
migration that creates a table without enabling RLS, a policy written as always
true, a service_role key reaching client code, a bucket created public in code.
The free tier is unlimited and runs on your machine. It only ever reads files
and never opens a network connection, so it cannot see what was changed in the
dashboard and cannot run the tests in step 5. Those stay yours to do.
Questions people ask
Is it a problem that my Supabase key is visible in the browser? Not if it is the publishable or anon key and your policies are in place: that key is meant to be public and reaches only what policies allow. It is serious if it is the service_role or secret key, which ignores policies entirely.
My app went blank after I turned on RLS. Did I break it? No, you locked it and have not cut a key yet. RLS with no policies denies every request arriving through the API with your publishable or anon key — which includes your own app. You will still see the rows in the SQL editor, because the role you use there bypasses RLS. Add a policy per table saying which rows a signed-in person may see.
Will anyone actually find my project URL? Yes. It sits inside the JavaScript your site sends every visitor, by design, and it can be collected at scale — which is exactly how 1,645 apps got scanned in one go.