The Supabase row level security default that leaks your entire user table
A table you create with SQL in Supabase has row level security off. The anon key that ships in your browser bundle can then read every row in it. This is the single most common serious finding in AI-built apps, and you can check yours with one curl request.
What is the Supabase RLS problem in one paragraph?
In Postgres, a newly created table has row level security disabled, and Supabase publishes every table in the public schema as a REST endpoint. Put those two facts together and a table created by a prompt, an AI agent, or a hand-run create table is readable by anyone holding your publishable anon key, which is in every browser that loads your site. It affects any app on Supabase where nobody explicitly wrote policies: Lovable, Bolt, Replit, Cursor, v0, or hand-written code. The fix is real SQL, it takes an afternoon, and it is not the same thing as hiding your key.
This is the failure that turns into a breach notification rather than a bug ticket, because the data is not stolen through a clever exploit. It is served, correctly, by your own API, to whoever asks. One developer who went looking wrote it plainly on r/lovable: "I have come across vibe-coded platforms with public Supabase endpoints exposing full user lists." Same thread, on the write side: "I could upgrade myself to premium, delete other users' data, or tamper with core records, all because PUT or PATCH endpoints were wide open."
What follows is the check you run against your own project, the mechanism explained properly so you can reason about your own schema instead of pattern-matching, the SQL that closes it, and an honest account of what correct RLS still does not protect you from.
How do I tell if my app is affected right now?
Run three checks, in this order: a curl request as an anonymous stranger, a psql inventory of every table, and the Supabase Security Advisor. The first one takes thirty seconds and answers the only question that actually matters.
Ask your own API for the data, as a stranger would
30 secondsYour project URL and anon key are already public. Open your deployed site, open dev tools, look at any request to supabase.co/rest/v1/, and copy the apikey header value. Or grep your built bundle for it. Then ask the API how many rows an anonymous caller can see.
export SUPABASE_URL="https://YOUR-PROJECT-REF.supabase.co"
export ANON_KEY="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." # the key already in your bundle
curl -s -D - -o /dev/null \
"$SUPABASE_URL/rest/v1/profiles?select=*" \
-H "apikey: $ANON_KEY" \
-H "Authorization: Bearer $ANON_KEY" \
-H "Range: 0-0" \
-H "Prefer: count=exact"
Read the content-range response header. It is the whole answer:
content-range: 0-0/4127 # 4,127 rows readable by an anonymous visitor. You are exposed.
content-range: */0 # zero rows visible. RLS is doing its job on this table.
HTTP/2 404 + "PGRST205" # table not exposed through the API at all. Fine.
The Range: 0-0 and Prefer: count=exact headers ask PostgREST for one row plus an exact total, so you get the row count without pulling the data down. Repeat for every table name you can remember. Then do the next check, because the tables you cannot remember are the ones that hurt.
Reading is half of it. The quotes above are about someone changing records, not just seeing them. Try an insert as an anonymous caller:
curl -s -w '\nHTTP %{http_code}\n' -X POST \
"$SUPABASE_URL/rest/v1/profiles" \
-H "apikey: $ANON_KEY" \
-H "Authorization: Bearer $ANON_KEY" \
-H "Content-Type: application/json" \
-d '{"full_name":"rls-probe-delete-me"}'
HTTP 201 means an anonymous stranger can write rows into that table, and you now have a test row to delete. HTTP 403 with "code":"42501" in the body is Postgres refusing the row because no policy permits it, which is the outcome you want. Anything else, read the error body: a 400 about a missing column is your schema complaining, not your security working.
Inventory every table, not the ones you remember
2 minutesConnect to your database with psql or the Supabase SQL editor and ask Postgres directly. This is the authoritative answer, and it covers the tables an agent created at 2am that you have never seen.
select
c.relname as table_name,
c.relrowsecurity as rls_enabled,
c.relforcerowsecurity as rls_forced,
count(p.polname) as policy_count
from pg_class c
join pg_namespace n on n.oid = c.relnamespace
left join pg_policy p on p.polrelid = c.oid
where n.nspname = 'public'
and c.relkind = 'r'
group by c.relname, c.relrowsecurity, c.relforcerowsecurity
order by c.relrowsecurity, policy_count, c.relname;
Three bad states show up in that output:
- rls_enabled = false. Wide open through the REST API. This is the one that leaks.
- rls_enabled = true, policy_count = 0. Locked shut. Nothing leaks, but nothing works either, and whatever is still working is probably going through the service role key, which brings its own problem.
- policy_count > 0 but the policy says
true. The most deceptive state, because the dashboard shows a green shield. Find these:
select tablename, policyname, roles, cmd, qual, with_check
from pg_policies
where schemaname = 'public'
and (qual = 'true' or with_check = 'true')
order by tablename, policyname;
Any row where qual is true is a policy that permits everyone. Note that for cmd = 'INSERT' a null qual is normal, insert policies only have a with_check. And read the roles column: {public} means the policy applies to every role including anon, which is what you get when a create policy statement omits the to clause.
RLS only matters on tables the API roles can reach at all. Supabase's default privileges hand new tables in the public schema to the anon and authenticated roles, which is why a fresh table is reachable without you doing anything. Confirm it for yourself:
select grantee, privilege_type
from information_schema.role_table_grants
where table_schema = 'public'
and table_name = 'profiles'
and grantee in ('anon', 'authenticated');
Read the warning Supabase is already showing you
1 minuteThe Supabase dashboard has an Advisors section with a Security tab. It runs a database linter and raises an error named RLS Disabled in Public for exactly this condition, plus related findings for views that bypass RLS and for functions with a mutable search path. It has probably been sitting there the whole time. Open it and read every error before you write another feature.
What does row level security actually do?
Row level security makes Postgres attach a where clause of your choosing to every query against a table, invisibly, at the database level, based on who is asking. It is not a firewall, a middleware, or an API gateway. It is a filter the planner injects into the query itself, which is why it cannot be forgotten by a route handler or bypassed by a crafted request.
Three separate things have to be true for a Supabase table to be safe, and confusing them is where almost everyone goes wrong.
One: the table is exposed. Supabase runs PostgREST in front of your database. Every table in the schemas listed under Data API settings, which by default is just public, becomes a REST resource at /rest/v1/<table>. You did not opt into that per table. It is the default, and it is the product.
Two: a role has privileges. PostgREST reads the JWT you send, takes its role claim, and runs your query as that Postgres role. No JWT, or the anon key's JWT, means the anon role. A logged-in user's JWT means the authenticated role. Supabase's default privileges already grant both of those roles access to tables in public, so the SQL-level permission check passes.
Three: RLS decides which rows. This is the only layer left, and it is the one that is off by default. create table in Postgres does not enable row level security. It never has. Postgres is behaving exactly as documented; the surprise is that the table is also, simultaneously, a public API endpoint.
When you run alter table x enable row level security, the default becomes deny. With RLS enabled and zero policies, a select returns zero rows and an insert raises error 42501. Policies are purely additive permissions: each one you add is a way in, and a row is visible if any applicable policy permits it. There is no such thing as a deny policy in Postgres RLS. That matters, because adding a second, sloppier policy later silently widens access, and nothing warns you.
Two clauses do different jobs. using is the filter applied to rows that already exist, so it governs select, update and delete. with check is applied to the row as it will exist after the write, so it governs insert and the result of an update. A policy with a correct using and a missing with check on update is the classic half-fix: the user can only modify their own rows, but nothing stops them from setting user_id to somebody else on the way out and handing the row away.
Is my exposed Supabase anon key the actual problem?
No. The anon key is designed to be public and belongs in your frontend bundle, and every Supabase app on the internet ships one. Rotating it fixes nothing. The key that is genuinely catastrophic to expose is the service role key, and the two get confused constantly.
Getting this distinction right is the difference between fixing the problem and doing a week of pointless work.
| Key | Postgres role it maps to | Subject to RLS? | Where it belongs |
|---|---|---|---|
anon (newer projects: publishable, sb_publishable_...) | anon | Yes, fully | Your browser bundle. This is correct and intended. |
| A logged-in user's access token | authenticated | Yes, fully | Issued by Supabase Auth, held in the browser, short-lived. |
service_role (newer projects: secret, sb_secret_...) | service_role, which has the Postgres bypassrls attribute | No. It ignores every policy you write. | Server-side environment variables only. Never a browser, never a mobile app, never a repo. |
Supabase is mid-migration on this naming, so expect to see either pair depending on when your project was created. Every project made before the rollout still shows anon and service_role, every project made after it defaults to sb_publishable_... and sb_secret_..., both pairs work at the same time right now, and Supabase has said it is retiring the legacy names by the end of 2026. The role, the RLS behaviour and the risk are identical either way: publishable is the new anon, secret is the new service_role.
So: public anon key plus correct RLS equals fine. That is the intended design, not a compromise. Public anon key plus no RLS equals an open database with a documented, self-describing REST API sitting in front of it, complete with filtering and ordering operators an attacker can use to page through everything you have.
The service role key is a different category of mistake entirely. It carries bypassrls, so it reads and writes every row in every table regardless of what policies exist. If it has ever been in client-side code, an NEXT_PUBLIC_ or VITE_ environment variable, a public repo, or a screenshot, treat it as compromised and rotate it. Find out today:
Is the service role key in your shipped bundle?
Criticalnpm run build
grep -rE "service_role|sb_secret_|SUPABASE_SERVICE" ./dist ./build ./.next 2>/dev/null
Zero results is the only acceptable answer. A JWT with "role":"service_role" in its payload decodes from any base64 decoder, so nobody has to be clever to find it. Also check that no environment variable holding it uses a client-exposing prefix: NEXT_PUBLIC_, VITE_ and REACT_APP_ are promises to your bundler that the value is public, and your bundler keeps that promise.
There is more detail on secret handling and rotation order in our vibe-coding security checklist, which covers this alongside thirteen other risks.
Why do AI coding tools keep leaving RLS off?
Because the two ways to create a table in Supabase have different defaults, and AI tools use the one that defaults to off. Creating a table through the dashboard's table editor presents an Enable Row Level Security checkbox that is ticked. Running create table as SQL does not enable RLS, because plain Postgres does not.
An AI coding tool writes SQL. It runs migrations, or it pastes a create table statement into the SQL editor, or it calls a management API. None of those paths go through the dashboard checkbox. So the table exists, the app works immediately, the demo is impressive, and the security default the dashboard would have given you never applied.
The second half of the problem is that the model, asked to make a broken query work, has an obvious and wrong move available. When a feature breaks because RLS is enabled with no policy, the smallest change that makes it work again is:
The policy that looks like security and is not
Criticalalter table public.profiles enable row level security;
create policy "Enable read access for all users"
on public.profiles
for select
using (true); -- true means: every row, to every role, including anon
This is worse than having no policy, because the dashboard now shows the table as protected and the Security Advisor's RLS Disabled in Public error goes away. Note also the missing to clause: omitting it means the policy applies to public, which includes anon. The app works, the warning clears, and the table is exactly as exposed as it was before.
This is also not evenly distributed across tools, and it is worth being precise rather than lumping them together. The r/lovable developer who audited a batch of these apps reported a tool-level difference: "Open REST endpoints are happening in Lovable mostly and not in Bolt. Bolt is setting up rules by default in Supabase, whereas Lovable isn't." That is one practitioner's observation from a set of apps he looked at, not a measured study, so treat it as a reason to check your own project rather than a reason to trust or distrust a vendor. The check above costs thirty seconds and settles it for your app specifically.
The deeper pattern, and the one worth internalising: an AI agent applies whatever it does to every table in the session. A human who forgets RLS forgets it on one table. An agent that does not write policies does not write them anywhere, which is why the finding is almost never a single table. It is the schema.
What is the correct RLS policy for a user-owned table?
Enable RLS, force it, then write one narrow policy per command, scoped to the authenticated role, comparing the caller's ID from their JWT against the row's owner column. Four policies, one index, and a revoke on the columns users must not be able to set themselves.
Assume a table public.profiles with a user_id uuid column referencing auth.users(id). Run this in the SQL editor or as a migration:
Turn it on, and make it apply to everyone
alter table public.profiles enable row level security;
alter table public.profiles force row level security;
enable switches RLS on for ordinary roles. force additionally applies policies to the table's owner, which is the role your migrations run as. Without force, a function or migration running as the owner silently sees everything, which is an easy way to convince yourself a policy works when it does not. Neither clause affects service_role, because bypassrls is a role attribute and sits above all of this.
One policy per command, scoped to a role
drop policy if exists "profiles_select_own" on public.profiles;
drop policy if exists "profiles_insert_own" on public.profiles;
drop policy if exists "profiles_update_own" on public.profiles;
drop policy if exists "profiles_delete_own" on public.profiles;
create policy "profiles_select_own"
on public.profiles for select
to authenticated
using ( (select auth.uid()) = user_id );
create policy "profiles_insert_own"
on public.profiles for insert
to authenticated
with check ( (select auth.uid()) = user_id );
create policy "profiles_update_own"
on public.profiles for update
to authenticated
using ( (select auth.uid()) = user_id )
with check ( (select auth.uid()) = user_id );
create policy "profiles_delete_own"
on public.profiles for delete
to authenticated
using ( (select auth.uid()) = user_id );
Four details in there are doing real work, and a generated policy usually gets at least one of them wrong.
to authenticated. Without it the policy targetspublic, which includesanon. An anonymous caller has noauth.uid(), so the comparison yields null rather than true and they still get nothing, but relying on a null comparison instead of saying what you mean is how the next policy on the table goes wrong. Be explicit.- A separate update policy with both clauses.
usingdecides which rows can be targeted;with checkdecides what they are allowed to become. Only havingusinglets a user reassign their own row'suser_idto someone else. - No blanket
for allpolicy.for allis convenient and it means the same expression governs reads and deletes. Users who may read a row very often must not delete it. (select auth.uid())rather than bareauth.uid(). Wrapping the call in a scalar subquery lets the planner evaluate it once as an InitPlan instead of re-invoking it per row. This is Supabase's own documented recommendation for RLS performance, and on a large table the difference is not subtle.
Index the column your policy filters on
create index if not exists profiles_user_id_idx
on public.profiles (user_id);
An RLS policy is a predicate on every query against the table, so an unindexed owner column turns every read into a sequential scan filtered after the fact. Check your own plan rather than taking anyone's word for it:
begin;
select set_config(
'request.jwt.claims',
'{"sub":"00000000-0000-0000-0000-000000000000","role":"authenticated"}',
true
);
set local role authenticated;
explain (analyze, buffers)
select * from public.profiles where user_id = auth.uid();
rollback;
Read the plan for two things. First, whether the access method is an Index Scan on profiles_user_id_idx or a Seq Scan with a Filter, which tells you whether the index is being used. Second, whether auth.uid() appears as an InitPlan evaluated once or inside the per-row filter expression. That transaction block is also the cheapest way to test any policy locally: Supabase defines auth.uid() as reading the sub claim out of the request.jwt.claims setting, so setting that GUC and switching role reproduces exactly what PostgREST does for a logged-in user, with no HTTP involved.
Stop users editing the columns that decide what they are worth
HighCorrect RLS lets a user edit their own row. It says nothing about which columns. If credits, plan, is_admin or role live on a row the user owns, an ownership policy happily lets them set those to whatever they like. This is the mechanism behind the r/lovable report: "I was able to get unlimited credits after changing the details of my profile within the browser."
revoke update (credits, plan, is_admin, role)
on public.profiles
from authenticated, anon;
Column-level privileges are checked independently of RLS, so this holds even though the row-level policy permits the update. The columns stay readable and the rest of the row stays editable. Anything that legitimately needs to change those values goes through a server-side path you control: a Postgres function marked security definer with its own checks, an Edge Function, or your own API route.
How do I prove the policy actually works?
Run the same probes again and watch the answers change, then test as a real second user. A policy you have not tested from the outside is a policy you are guessing about.
Three tests, in order of how much they prove:
- The anonymous curl from earlier. Re-run it.
content-rangeshould now read*/0, and the anonymous insert should return 403 with code 42501. That proves strangers are out. - Two real accounts. Sign up as user A and user B in a private window, note a row ID belonging to B, then request it while holding A's access token. An empty array is correct. This proves the policy scopes by owner and not just by "logged in", which is a different bug with its own article: IDOR, where a logged-in user is treated as an authorized user.
- The in-database simulation. The
set_configplusset local roleblock above, run once per role, with the transaction rolled back. Fast enough to put in a test suite, and it does not require running your app at all.
Do this table by table. There is no shortcut that covers a schema you have not enumerated, which is why check 02 comes before the fix rather than after it.
What does correct RLS still not protect you from?
RLS decides which rows a role may touch. It does not decide which columns, it does not apply to anything using the service role key, and it does not know anything about your business rules. Four gaps survive a perfect set of policies.
- Views and functions that bypass it. A view executes with its owner's permissions unless it is created with
security_invoker = true(Postgres 15 and later), so a view over a protected table can hand out every row. The same is true of any function declaredsecurity definer. Supabase's Security Advisor flags both, and they are the most common way a schema with correct-looking policies still leaks. - Everything your server does with the service role key. Edge Functions, Next.js route handlers, and cron jobs holding
service_roleignore RLS entirely. Every one of those needs its own ownership check written by hand. If your app fetches a record by ID in a server route using the service key, RLS will not save you. - Storage. Supabase Storage enforces access through policies on
storage.objects, which is a separate set of policies from your tables, and a bucket marked public is public. Uploaded IDs, contracts and avatars are frequently the most sensitive data in the project and the least-checked. - Rate and volume. A policy that correctly lets each user read their own rows does not stop one user from reading their own rows ten thousand times a minute, or from enumerating every ID they have ever been shown. That is a rate-limiting and logging problem.
There is also the question RLS cannot answer at all: whether anyone already pulled the data while it was open. That is a log question, not a code question, and it is time-sensitive: Supabase's published log retention for API and database logs is 1 day on Free, 7 days on Pro, 28 days on Team and 90 days on Enterprise, at time of writing. If your probe returned rows, export your API logs before you do anything else.
When should I stop and hire an engineer?
If your app has fewer than about ten tables, no paying customers, and the probe returned zero rows, do not hire anyone. Run the inventory query, write the four policies per table, re-run the probes, and you are done in an afternoon. That is a real answer, not a hedge.
Get help when one of these is true, and each of these is a specific, bounded job rather than an open-ended engagement:
- The probe returned rows and you have real users. The code fix is the easy half. Working out what was exposed, for how long, whether anyone took it, and what your notification obligations are under GDPR or your state's breach law is the half that needs someone who has done it. It is also the half with a clock on it.
- The service role key has ever been in client code or a public repo. Rotating it means finding every consumer first, cutting over, and revoking last, without taking production down mid-rotation. Doing that in the wrong order takes the app offline.
- You cannot enumerate your own schema. If the inventory query returns tables you have never heard of, something else created them, and the policies are the second problem. The first is understanding what your database actually contains.
- Policies exist and you cannot tell whether they are right. Reading someone else's
usingexpressions across thirty tables and knowing which ones are subtly wrong is a specific skill. An AI asked "is this secure?" will tell you it looks good.
If none of those apply, close this tab and go write the policies. Genuinely. The best outcome of this article is that you never talk to us.
Export your Supabase API logs first, then fix the policies, then rotate any key that was ever client-side, then work out disclosure. In that order. Our AI app security audit starts with exactly this triage, and if the honest answer after an hour is that you can finish it yourself, that is what we will tell you.
The other two failures that show up alongside this one.
Also relevant: fixing Lovable and Supabase security and the state of vibe-code security.
The rescue playbook.
Real prompts, security rules, and teardowns of broken AI-built apps, sent when we publish something worth your inbox. No fluff, no drip sequence.
One or two emails a month. Unsubscribe anytime.
Want someone to run the whole schema, not just the tables you remember?
We enumerate every table, view, function and bucket, probe each one as an anonymous caller and as a second user, and hand you a prioritized findings list with the SQL to close each gap.
Common questions about Supabase RLS.
Is my Supabase anon key being exposed a security problem?
On its own, no. The anon key is designed to be public and every Supabase app on the internet ships one in its browser bundle. It is safe only because row level security, not the key, is supposed to be the thing deciding which rows a request can reach. A public anon key with correct policies is fine. A public anon key with row level security off is an open database. The key that is genuinely catastrophic to expose is the service role key, because it carries the Postgres bypassrls attribute and ignores every policy you write.
Does enabling row level security automatically protect my table?
Enabling it is only half the job. With row level security enabled and no policies at all, Postgres denies everything, which is secure but breaks your app. The dangerous middle state is a policy written as using (true), which permits every row to every role including anon while making the dashboard show the table as protected and clearing the Security Advisor warning. Run select tablename, policyname, roles, cmd, qual from pg_policies where schemaname = 'public' and look for any policy whose qual is true.
Why does my AI coding tool leave row level security off?
Because AI tools create tables by running SQL, and plain Postgres does not enable row level security on a newly created table. The Supabase dashboard's table editor presents an Enable Row Level Security checkbox that is ticked by default, but a create table statement run through the SQL editor, a migration, or a management API never touches that checkbox. The table works immediately, the app demo succeeds, and the default the dashboard would have given you never applied.
How do I know if someone already read my data?
That is a log question rather than a code question, and it is time-sensitive: Supabase's published log retention for API and database logs is 1 day on Free, 7 days on Pro, 28 days on Team and 90 days on Enterprise, at time of writing. Export your API logs before you change anything, then look for request volume against the exposed table from addresses that are not your own app, unusually large range requests, and requests using filter operators your frontend never sends. If the data was exposed and you have real users, the disclosure question is a legal one and needs an answer before the logs age out.
Can I just fix this myself instead of hiring someone?
If your app has fewer than about ten tables, no paying customers, and the anonymous curl probe returned zero rows, yes, and you should. Run the inventory query against pg_class and pg_policies, write four policies per table scoped to the authenticated role, index the owner column, then re-run the probes. That is an afternoon of work. Get help when the probe returned real rows and you have real users, when the service role key has ever been in client code, or when the inventory query returns tables you have never heard of.