DEV Community

Vishal Chauhan
Vishal Chauhan

Posted on

Your Vibe-Coded App Might Be Leaking User Data. Here's How to Lock Down Supabase in 30 Minutes

If you built your app with Lovable, Bolt, Cursor, or Claude Code, there's a good chance your database is Supabase. That's a great choice. But it comes with one setting that AI tools often get wrong, and when they do, anyone on the internet can read (and sometimes edit) your users' data without logging in.

This isn't a scare story. It's a known pattern. A CVE was even filed for it in 2025 (CVE-2025-48757), describing insufficient Row Level Security in sites generated by Lovable. Lovable disputes it, saying each customer is responsible for protecting their own app's data. And that's the point: whoever built the app, it's your job to check.

The good news: you can check and fix it in about 30 minutes, even if you've never written SQL. Here's the exact process.

The 60-second version of how this works

Your app's frontend talks straight to your database from the browser. To do that, it ships two things inside your website's code:

  • Your project URL (like https://abcd.supabase.co)
  • A public key: the publishable key (sb_publishable_...) or the older anon key (a long string starting with eyJ)

Both are meant to be public. Anyone can open DevTools and see them. That's fine, as long as your tables have Row Level Security (RLS) turned on.

  • RLS = a lock on each table. When it's on, the public key can only touch rows a rule allows.
  • Policy = one of those rules, like "users can only read their own rows."
  • Secret key (sb_secret_..., or the older service_role key) = the master key. It skips RLS completely. It must never be in your frontend.

Supabase's own docs say it plainly: a table in an exposed schema without RLS is readable and writable by any role with a grant on it. So: RLS on, good policies, secret key hidden. That's the whole job.

Step 1: Run the 2-minute "stranger test"

Before touching anything, find out if you have a problem.

  1. Open your live app in an incognito window (logged out).
  2. Open DevTools > Network tab and click around. Find a request going to your-project.supabase.co/rest/v1/....
  3. Copy your project URL and the apikey header value from that request.

Now ask your database for data the way a stranger would. Replace the table name with one of yours (profiles, users, orders, messages...):

curl "https://YOUR-PROJECT.supabase.co/rest/v1/profiles?select=*&limit=5" \
  -H "apikey: YOUR_PUBLIC_KEY"
Enter fullscreen mode Exit fullscreen mode

What you get back tells you everything:

  • [] (empty list) or a permission error: good, logged-out visitors can't read that table.
  • Real rows with emails, names, or messages: that table is open to the whole internet. Keep reading.

Repeat for every table that holds anything private. Only test your own app.

Step 2: Let Supabase's Security Advisor find the holes

You don't have to hunt manually. Supabase ships a Security Advisor that checks your project for these mistakes. Open your project dashboard and find Advisors > Security Advisor (or run supabase db advisors if you use the CLI).

The findings that matter most for beginners:

Finding What it means in plain words
Table publicly accessible (rls_disabled_in_public) RLS is off. Anyone with your URL can read, edit, and delete rows. Fix first.
Sensitive data publicly accessible Same as above, on a table with columns like password, token, or api_key.
Security policy allows unrestricted access You have a policy like using (true), so the lock is on but the door is propped open.
Security policy not enforced You wrote policies but never turned RLS on, so they do nothing.
View bypasses row-level security A database view is leaking rows your policies were meant to hide.
Policy relies on user-editable data A rule trusts user_metadata, which users can change themselves.

Want a quick list of your own tables and their RLS status? Paste this into the SQL Editor:

select tablename, rowsecurity as rls_on
from pg_tables
where schemaname = 'public'
order by rowsecurity, tablename;
Enter fullscreen mode Exit fullscreen mode

Anything showing false needs attention.

Why does this happen so often? Tables your AI tool creates with raw SQL create table statements don't get RLS turned on automatically. Postgres starts every table with RLS off unless you (or a migration) enable it.

Step 3: Make sure the master key isn't in your frontend

This one takes five minutes and matters more than anything else. Search your project for these strings:

grep -rn "service_role\|sb_secret_\|SERVICE_ROLE_KEY\|SECRET_KEY" \
  --exclude-dir=node_modules --exclude-dir=.git .
Enter fullscreen mode Exit fullscreen mode

Then check where each match lives:

  • In a server route, API handler, or Edge Function: fine.
  • In a React component, a .env variable starting with NEXT_PUBLIC_ or VITE_, or anything the browser loads: not fine. Those prefixes tell your bundler to ship the value to every visitor.

Not sure which key a long eyJ... string is? It's a JWT, and the middle part says its role. Decode it locally:

node -e "console.log(JSON.parse(Buffer.from(process.argv[1].split('.')[1],'base64url')))" "PASTE_KEY_HERE"
Enter fullscreen mode Exit fullscreen mode

"role": "anon" is the public key (fine). "role": "service_role" is the master key.

If the master key was ever public: fix the code first, then follow Supabase's rotation steps: create a new secret key in Settings > API Keys, swap it in everywhere, confirm everything uses the new one, and only then delete the old secret key (or deactivate the legacy keys). Moving the key out of your frontend isn't enough on its own, because anyone who copied it can still use it.

Step 4: Turn on RLS and add "owner only" rules

Here's the template for the most common case: a table where each row belongs to one user, through a user_id column. Swap notes for your table name and run it in the SQL Editor:

-- 1. Lock the table
alter table public.notes enable row level security;

-- 2. Remove default access, give back only what signed-in users need
revoke all on table public.notes from anon, authenticated;
grant select, insert, update, delete on table public.notes to authenticated;

-- 3. One rule per action: you can only touch your own rows
create policy "read own notes" on public.notes
  for select to authenticated
  using ((select auth.uid()) = user_id);

create policy "create own notes" on public.notes
  for insert to authenticated
  with check ((select auth.uid()) = user_id);

create policy "update own notes" on public.notes
  for update to authenticated
  using ((select auth.uid()) = user_id)
  with check ((select auth.uid()) = user_id);

create policy "delete own notes" on public.notes
  for delete to authenticated
  using ((select auth.uid()) = user_id);
Enter fullscreen mode Exit fullscreen mode

A few details that trip people up:

  • to authenticated means logged-out visitors never even reach the rule.
  • with check on update stops a user from changing user_id to hand a row to someone else.
  • (select auth.uid()) in brackets is the form Supabase recommends: same result, but it runs once per query instead of once per row, which keeps things fast as you grow.
  • The revoke/grant lines matter. Supabase's docs warn that adding policies doesn't remove default grants, so a table protected only by policies can still give logged-out visitors a way to insert rows.

Step 5: Handle the tables that should be public

Not everything is private. Two common cases:

Public content (blog posts, a pricing table, a public product list): readable by anyone, writable by nobody from the browser.

alter table public.posts enable row level security;
revoke all on table public.posts from anon, authenticated;
grant select on table public.posts to anon, authenticated;

create policy "anyone can read posts" on public.posts
  for select to anon, authenticated
  using (true);
Enter fullscreen mode Exit fullscreen mode

using (true) is fine here only because the data is meant to be public. If your posts table also has drafts, use using (published = true) instead.

A waitlist or contact form: visitors can add a row, but nobody can read the list from the browser.

alter table public.waitlist enable row level security;
revoke all on table public.waitlist from anon, authenticated;
grant insert on table public.waitlist to anon;

create policy "anyone can join" on public.waitlist
  for insert to anon
  with check (char_length(email) between 3 and 254);
Enter fullscreen mode Exit fullscreen mode

No select grant means even you can't read it from the frontend. Read it in the dashboard instead.

Step 6: Check the three sneaky leaks

Tables are the big one, but the Security Advisor also flags these. Know what they mean:

  1. Views. Postgres views ignore RLS by default. If your AI created a view, recreate it with (security_invoker = true) so it respects the table's rules.
  2. Database functions. A function marked security definer runs with admin powers and can be callable by anyone at /rest/v1/rpc/.... If it doesn't need to be public, revoke it: revoke execute on function public.my_function from anon, authenticated, public;
  3. Storage buckets. A public bucket plus a broad "select" rule lets anyone list every file in it. Public file URLs keep working without that rule, so remove it unless you really need listing.

Step 7: Test it like two different people

Policies that look right can still be wrong. Do this:

  1. Re-run the Step 1 curl test logged out. Private tables should return [] or an error.
  2. Create two test accounts, User A and User B, in two different browsers.
  3. As User A, create a note. As User B, try to view, edit, and delete it (use the app, and try changing IDs in the URL).
  4. Confirm User A can still do everything they should. A rule that blocks everyone isn't secure, it's broken.

If you're comfortable with the CLI, Supabase also supports database tests (supabase test db) where you assert that each role can or can't do each action. Worth adding once you have paying users.

A copy-paste prompt for your AI tool

Your AI can do a lot of this, but it shouldn't grade its own homework. Use it to draft, then verify with Steps 1 and 7 yourself:

Audit my Supabase setup for data exposure. For every table in the public schema:
1. Tell me if RLS is enabled and list its current policies.
2. Tell me who should access it: owner only, public read, or insert-only.
3. Write one SQL migration that enables RLS, revokes default grants from anon
   and authenticated, grants only what is needed, and adds one policy per
   action (select, insert, update, delete) using (select auth.uid()) = user_id
   for owner-only tables. Use "to authenticated" unless data is truly public.
4. Never use "using (true)" except for intentionally public read-only data.
5. Search the codebase for service_role or sb_secret_ keys and tell me if any
   can reach the browser (client components, NEXT_PUBLIC_ or VITE_ env vars).
Do not run anything. Show me the migration and findings first.
Enter fullscreen mode Exit fullscreen mode

Common mistakes to avoid

  • "Hiding" the public key. The publishable/anon key is designed to be public. Rotating it or proxying it doesn't fix an open table. RLS does.
  • Turning RLS on and stopping. With no policies, your app breaks (nothing can be read). With using (true), nothing is protected.
  • Trusting user_metadata for roles. Users can edit it. Store roles like "admin" in your own table or in app_metadata.
  • Testing only through your UI. Hiding a button isn't security. The curl test talks to the database directly, the way an attacker would.
  • Fixing only the table you noticed. Run the Advisor again after every new feature your AI builds. New tables are where this comes back.

Your 30-minute action plan

  1. Run the incognito curl test on every private table (5 min).
  2. Open the Security Advisor and list every error (5 min).
  3. Grep for service_role / sb_secret_ and rotate if one ever shipped (5 min).
  4. Apply the owner-only template to each private table (10 min).
  5. Set public and insert-only tables up on purpose, and check views, functions, and buckets (5 min).
  6. Re-test as logged-out, User A, and User B.

Do this once now, then make "run the Security Advisor" part of your routine every time you ship a feature that adds a table. It's the cheapest insurance your app will ever have.

Top comments (1)

Collapse
 
dominicpaaul profile image
Dominic Paul •

Good walkthrough. One addition for apps that also have server code (Next.js API routes, Edge Functions, a small Express backend): the stranger test only covers the browser path. Any server route that creates a client with the secret / service_role key skips RLS entirely, so the policies you just wrote don't apply there. It's worth listing every server route that uses the secret key and checking that each one verifies the user (auth.getClaims() or auth.getUser()) and filters by their id before it queries. The curl test can't see that gap.