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 witheyJ)
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 olderservice_rolekey) = 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.
- Open your live app in an incognito window (logged out).
- Open DevTools > Network tab and click around. Find a request going to
your-project.supabase.co/rest/v1/.... - Copy your project URL and the
apikeyheader 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"
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;
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 .
Then check where each match lives:
- In a server route, API handler, or Edge Function: fine.
- In a React component, a
.envvariable starting withNEXT_PUBLIC_orVITE_, 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"
"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);
A few details that trip people up:
-
to authenticatedmeans logged-out visitors never even reach the rule. -
with checkon update stops a user from changinguser_idto 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/grantlines 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);
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);
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:
-
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. -
Database functions. A function marked
security definerruns 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; - 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:
- Re-run the Step 1 curl test logged out. Private tables should return
[]or an error. - Create two test accounts, User A and User B, in two different browsers.
- 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).
- 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.
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_metadatafor roles. Users can edit it. Store roles like "admin" in your own table or inapp_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
- Run the incognito curl test on every private table (5 min).
- Open the Security Advisor and list every error (5 min).
- Grep for
service_role/sb_secret_and rotate if one ever shipped (5 min). - Apply the owner-only template to each private table (10 min).
- Set public and insert-only tables up on purpose, and check views, functions, and buckets (5 min).
- 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)
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()orauth.getUser()) and filters by their id before it queries. The curl test can't see that gap.