Supabase lets your frontend query Postgres directly. That's a large part of why AI app builders like it: there's no backend to write. It also means your database is reachable from the public internet, and the key your app uses to reach it is visible to anyone who opens developer tools.
That's not a mistake on Supabase's part. The anon (or publishable) key is meant to be public. It identifies your project and tells the database the request comes from an anonymous user. What decides which rows that user can see is row-level security (RLS): Postgres policies attached to each table. When RLS is set up properly, a public key is harmless. When it isn't, the key opens everything.
How much this matters became clear in 2025, when a researcher scanned 1,645 apps built with Lovable and found about 170 whose Supabase tables could be read or written using only the public key. The issue became CVE-2025-48757. Lovable disputes the CVE, saying each customer is responsible for their own data. Whoever is right, the practical lesson is the same: check your own policies.
Check your project first
Start with the Security Advisor in the Supabase dashboard, which flags tables in exposed schemas that have RLS disabled. Then run this query in the SQL editor to see every table in the public schema and whether RLS is on:
select tablename, rowsecurity as rls_enabled
from pg_tables
where schemaname = 'public'
order by rowsecurity, tablename;Finally, test what an anonymous visitor actually gets. Use your project's URL and anon key, the same values your frontend uses, against a table that should be private. Only do this against a project you own.
curl "https://<your-project>.supabase.co/rest/v1/profiles?select=*&limit=5" \
-H "apikey: $SUPABASE_ANON_KEY" \
-H "Authorization: Bearer $SUPABASE_ANON_KEY"
# Expected for a private table: []
# If you see other users' rows, RLS is off or a policy is too broad.Mistake 1: RLS was never turned on
Supabase enables RLS automatically for tables created in the dashboard's Table Editor. Tables created with SQL, in the SQL editor or in migration files, start with RLS off. AI assistants usually generate SQL migrations, so their tables often start out unprotected.
alter table public.profiles enable row level security;
alter table public.orders enable row level security;
-- repeat for every table in publicOnce RLS is on and no policies exist, every request from the anon and authenticated roles returns nothing. That's the safe default. It also leads straight to the next mistake.
Mistake 2: A policy that allows everything
After RLS is enabled, the app suddenly shows empty lists. Ask an AI assistant to fix that and a common answer is a policy with using (true). The data comes back and the app works again, but the policy lets everyone read every row, which is where you started.
-- Often generated to make the error go away. Anyone can read every profile.
create policy "Enable read access for all users"
on public.profiles for select
using (true);
-- What you usually want: signed-in users read only their own row.
create policy "Users can read their own profile"
on public.profiles for select
to authenticated
using ((select auth.uid()) = id);using (true) is fine for data that really is public, such as a product catalog. For anything tied to a user or a team, the policy should compare a column against auth.uid() or a membership table. Write separate policies for select, insert, update, and delete, and only add the ones the app needs.
Mistake 3: Roles read from user-editable metadata
To add admin features, assistants sometimes store a role in the user's metadata and check it in a policy. The problem is that user_metadata can be changed by the user through the regular client library. Anyone can make themselves an admin with one call to supabase.auth.updateUser.
-- Unsafe: user_metadata is writable by the user
using ((auth.jwt() -> 'user_metadata' ->> 'role') = 'admin')
-- Safer: app_metadata can only be set server-side
using ((auth.jwt() -> 'app_metadata' ->> 'role') = 'admin')app_metadata can only be changed with the service role key, so users can't edit it. For anything more complex than one admin flag, a roles or memberships table that only the server can write to is easier to reason about and audit.
Mistake 4: Views that quietly skip RLS
Views are a neat way to give the frontend a joined or summarized table. But in Postgres, a view runs with the privileges of the user who created it. In Supabase that's usually the postgres role, which bypasses RLS. A view over a protected table can expose every row of it.
create view public.order_summaries
with (security_invoker = true)
as
select id, user_id, total, created_at
from public.orders;
-- Or fix an existing view
alter view public.order_summaries set (security_invoker = true);On older Postgres versions, revoke access to the view from the anon and authenticated roles, or move it to a schema that isn't exposed through the API. The same caution applies to database functions marked security definer in the public schema, because they can be called through the API and also run with elevated privileges.
Mistake 5: The service role key in the frontend
The service role (or secret) key bypasses RLS entirely. It belongs only on a server. If a policy is blocking something and the assistant switches the frontend to the service role key, every policy you wrote stops applying for every visitor.
Search your code and environment files for the service role key in any variable starting with NEXT_PUBLIC_ or VITE_. If it was ever exposed in a deployed bundle or a public repository, rotate it in the dashboard, then move the code that needed it into a server route.
Keep it fixed
RLS problems tend to come back, because every new table needs its own policies and every AI-generated migration is a chance to forget. Some habits that help:
- Keep RLS and policies in migration files under version control, so they're reviewed like code.
- Add a CI step that runs the pg_tables query above and fails if any public table has RLS disabled.
- Write policy tests with two test users using supabase test db (pgTAP), checking that user B can't read, update, or delete user A's rows.
- Re-run the Security Advisor after every schema change, not just before launch.
Where we stand: this guide is based on Supabase's public documentation and standard Postgres behavior as of October 2026. Details of CVE-2025-48757 come from the public CVE record and published research. Test only projects you own or have written permission to assess.
