If you built your app by describing it to an AI, there is a very good chance your database is open right now. Not because you did anything wrong, but because the tool did its job too well: it made the app work, and a Supabase app looks completely finished long before it is actually safe. This is the single most reported failure in vibe-coded apps, and it has its own shape. Let us fix it.

This is the deep dive on one item from our broader pre-launch security checklist for vibe-coded apps. That article is the afternoon sweep across everything; this one is the table-by-table walkthrough of the part that takes most apps down.

Why the AI leaves your tables wide open

Supabase has a feature that is brilliant and dangerous in equal measure: it automatically generates a REST API for your whole database. Every table becomes an endpoint at a URL like yourproject.supabase.co/rest/v1/your_table. That is why the AI can build a working app so fast, it never has to write a backend. The browser talks to your database directly.

The catch is the security model. That direct line is gated by one thing only: Row Level Security policies on each table. Per the Supabase docs, a table without RLS enabled is open through that API. With no policy on it, the API treats the table as public, and a logged-out stranger holding your public key can read every row, and often write to it too.

The AI builder creates the tables, writes the queries, makes the screens render, and stops. The policy step is invisible in the running app, so it gets skipped. You can watch makers discover this in real time. The senior engineer who reviewed vibe-coded apps on r/vibecoding found the same pattern in every one: "RLS misconfigured on at least one table." Not "off everywhere," which makers tend to catch. On for most tables, missed on one, which nobody catches until someone goes looking. A widely shared "Security in vibe-coded apps is a disaster" thread walks strangers through finding these exposed endpoints in the Network tab of any flashy new app. The most public incident, CVE-2025-48757, was exactly this: 170-plus Lovable-built projects with Supabase tables anyone could read using only the public key.

The exposure test: prove it to yourself in two minutes

Do not trust the dashboard's green checkmarks. Test the live behaviour, the way an attacker would.

  1. Open your deployed app and open your browser dev tools (F12), Network tab.
  2. Click around so the app loads data. Find a request to yourproject.supabase.co/rest/v1/<table>. Note the table names and grab the apikey header value: that is your public anon key, and it is meant to be public.
  3. Now act as a stranger. Open a fresh private window with no session, or use the terminal, and ask the API for that table directly with just the public key:
curl "https://yourproject.supabase.co/rest/v1/notes?select=*" \
  -H "apikey: YOUR_PUBLIC_ANON_KEY"

If that returns rows, the table is public to the entire internet. No login, no trick, just the key that is already in everyone's browser. Run it against every table that holds user data: profiles, orders, messages, anything private. An empty array [] or a permission error is what you want to see. A list of your users is the incident.

This is the test to take seriously even if the dashboard says RLS is enabled, because "enabled with no policy" and "enabled on most tables" both pass a glance and fail this curl.

The fix, part one: enable RLS (and understand what it does)

Per the Supabase documentation, you turn it on per table with one line of SQL, run from the Supabase SQL Editor:

alter table notes enable row level security;

Here is the part that trips people up, and it is the safe behaviour: once RLS is enabled and there are no policies yet, the table denies everyone, including the legitimate owner. Your app will suddenly show empty lists. That is correct. RLS is deny-by-default; you now have to explicitly grant access back with policies. The dangerous state is the opposite, RLS off, where everything is allowed.

The fix, part two: write a policy per action

A policy answers "who can do what to which rows." You write one per action (SELECT, INSERT, UPDATE, DELETE) so you can grant reads without accidentally granting writes. Two clauses do the work, and mixing them up is the second most common mistake after forgetting policies entirely:

The everyday pattern is "each user only touches their own rows," using auth.uid(), which returns the logged-in user's id, matched against a user_id column on the table. Here is the full owner-scoped set, taken straight from the Supabase docs patterns:

-- Read: a user sees only their own rows
create policy "Users can read own notes"
on notes
for select
to authenticated
using ( (select auth.uid()) = user_id );

-- Insert: a user can only create rows owned by themselves
create policy "Users can insert own notes"
on notes
for insert
to authenticated
with check ( (select auth.uid()) = user_id );

-- Update: must own the row, and cannot reassign it to someone else
create policy "Users can update own notes"
on notes
for update
to authenticated
using ( (select auth.uid()) = user_id )
with check ( (select auth.uid()) = user_id );

-- Delete: a user can only delete their own rows
create policy "Users can delete own notes"
on notes
for delete
to authenticated
using ( (select auth.uid()) = user_id );

Two details worth not skipping. First, to authenticated scopes the policy to logged-in users; the anon (logged-out) role still gets nothing, which is exactly what you want for private data. Second, the Supabase docs recommend wrapping the helper as (select auth.uid()) rather than a bare auth.uid(). That lets the Postgres planner cache the value once per query instead of recomputing it for every row, which is a real performance difference on tables that grow. It is the same syntax either way, so use the fast one from the start.

If a table is genuinely public read-only, say a list of published blog posts, the policy is honest about it:

create policy "Published posts are public"
on posts
for select
to anon, authenticated
using ( published = true );

Note that even "public" is a deliberate select-only grant with a condition (published = true), not an open door. Drafts stay private because the condition excludes them.

The anon vs service_role key trap

This is where makers panic and make it worse. After reading that the key is in the browser, the instinct is to hide it or swap it for the "real" key. Both are wrong.

Supabase ships two keys, and the difference is the whole game:

The trap has two jaws. Hiding the anon key is pointless, it is supposed to be visible, and obscuring it does nothing because the policy is the real boundary. Shipping the service_role key to the browser to "make a query work" is catastrophic, because it ignores every policy you just wrote and hands the whole database to anyone who views source. If you ever find service_role in your client-side bundle, that is not a small issue: treat that key as leaked, rotate it immediately in the dashboard, and move that call to a server-side function. The rule generalises across providers, and we cover the broader "right key on the right side" discipline in the security checklist.

Testing that your policies actually work

Writing a policy is not the same as proving it works. Three checks, in order:

  1. Re-run the exposure curl. The same logged-out request from the test above should now return an empty array or an error, not rows. This proves the anon role is blocked.
  2. Cross-account read. Log in as user A, create a row, then log in as user B and try to read or edit A's row through the app. B should see nothing. This proves auth.uid() scoping works, not just that login works.
  3. Owner still works. Confirm the legitimate owner can still read, create, and edit their own rows. RLS that locks everyone out is "secure" but broken.

Run all three after every schema change, because the failure mode the engineers keep finding is a new table added later without a policy. Each fresh table is open until you say otherwise. If you let the AI add a table, you have to add the policies in the same breath.

The mindset for vibe coders

You do not have to become a database administrator. You have to internalise one rule: in Supabase, the table is public until a policy says it is not, and the AI will not write that policy for you reliably. So the new habit is, every time a table is created, enable RLS and write its policies before you ship the screen that uses it. That is four short SQL statements you can paste and adapt, and it is the difference between a launch and showing up in the next "I reviewed your app and your whole user table is public" thread.

Once your data layer enforces who can touch what, the rest of the security checklist gets a lot shorter. If you are weighing whether Supabase is even the right backend for where your app is heading, our pieces on when to graduate from no-code and choosing a real data layer over a spreadsheet cover the next decision, and if you are exposing your data to AI agents, the MCP security checklist applies the same deny-by-default thinking one layer up.

Build fast. Lock the data. The order is the whole game.