If a table in your Supabase project's public schema has Row Level Security turned off, anyone with the publishable (or legacy anon) key that ships in your frontend can read it, and usually write to it, through the auto-generated REST API. You can find out in fifteen minutes: one SQL query lists the tables without RLS, and one curl call with your own public key shows what a stranger sees. Fixing it means enabling RLS on every exposed table, writing a policy for each operation, closing the paths that skip RLS entirely, and putting the check in CI so the next table cannot ship without it.
Key takeaways
- The publishable key is public by design. Grants and RLS policies are the only things between it and your data.
- Tables created from SQL, migrations or a coding agent do not get RLS automatically. The dashboard's Table Editor turns it on; raw SQL does not.
- RLS with no policy denies everything to client roles, which is the safe failure. A policy using
true, or one that trusts a column the user can write, is the dangerous one. - Secret keys in the client, views,
SECURITY DEFINERfunctions, materialized views and public storage buckets all get around RLS. Check each one. - From October 30, 2026, new tables in
publicon existing projects get no automatic grants. Fix the resulting42501with a minimal grant in the same migration as RLS and policies, never withGRANT ALL. - A pgTAP test in CI is cheaper than the incident.
What UpGuard found and why it is a configuration problem
In late September 2026, UpGuard published what it calls the largest study of its kind on Supabase exposure. Its researchers took roughly 300,000 domains showing signs of Supabase usage and asked each one's database for a table called users. They found 16,326 databases exposing readable tables; judging by table schemas, more than half showed indicators of personal data, and a smaller share included passwords or authentication tokens.
UpGuard's explanation of the cause matters more than the number. Supabase now enables RLS by default for tables created in the Table Editor, but tables created programmatically, which is how coding agents work, do not get it. Some projects had policies that did not actually restrict access, and some treated public keys as if they were secret.
Note how the researchers found tables beyond users: when that table did not exist, the API's error response hinted at the name of one that was accessible. Supabase stopped serving the schema description at /rest/v1/ to the anon key earlier this year, but table names still leak through hints and through your JavaScript bundle. Assume an attacker knows them.
None of this is a Supabase vulnerability. Supabase is Postgres behind PostgREST. The public key maps a request to the anon role, or to authenticated once a user signs in, and Postgres checks two layers: grants decide whether the role can touch a table at all, and RLS policies decide which rows. A table with grants and no RLS is public. That is a configuration choice, often one nobody knew they were making.
The 15-minute exposure test (your own key, your own tables)
Run this against a project you own. Every step is read-only; never point write tests at production.
Step 1: inventory every exposed relation
In the SQL Editor:
select
c.relname as name,
case c.relkind
when 'r' then 'table' when 'p' then 'partitioned table'
when 'v' then 'view' when 'm' then 'materialized view'
when 'f' then 'foreign table'
end as kind,
c.relrowsecurity as rls_on,
(select count(*) from pg_policies p
where p.schemaname = n.nspname and p.tablename = c.relname) as policies,
has_table_privilege('anon', c.oid, 'select') as anon_select,
has_table_privilege('anon', c.oid, 'insert') as anon_insert,
has_table_privilege('authenticated', c.oid, 'select') as authed_select
from pg_class c
join pg_namespace n on n.oid = c.relnamespace
where n.nspname = 'public'
and c.relkind in ('r', 'p', 'v', 'm', 'f')
order by c.relrowsecurity, c.relname;Read it like this:
| Row looks like | Meaning |
|---|---|
table, rls_on false, anon_select true | Anyone with your public key can read every row |
table, rls_on false, anon_insert true | Anyone can also write |
table, rls_on true, policies 0 | Locked to client roles (safe, but your app cannot use it yet) |
view or materialized view, anon_select true | Probably bypasses RLS; see below |
Step 2: read every policy
select tablename, policyname, cmd, roles, qual, with_check
from pg_policies
where schemaname = 'public'
order by tablename, cmd;Look for qual or with_check equal to true, policies whose roles include public or anon on tables that are not meant to be public, and any expression that reads user_metadata.
Step 3: ask the API what a stranger sees
Collect your table names the way an attacker would, from your own code:
grep -rhoE "\.from\(['\"][A-Za-z0-9_]+['\"]\)" src | sort -uThen, with the publishable key your frontend ships, ask each table how many rows it would return:
export SUPABASE_URL="https://your-project-ref.supabase.co"
export SUPABASE_KEY="sb_publishable_..." # the key in your frontend bundle
for t in profiles orders messages; do
printf '%-12s ' "$t"
curl -s -o /dev/null --dump-header /dev/stdout \
"$SUPABASE_URL/rest/v1/$t?select=*&limit=1" \
-H "apikey: $SUPABASE_KEY" \
-H "Prefer: count=exact" | grep -iE '^(HTTP|content-range)' | tr -d '\r' | tr '\n' ' '
echo
done| Response | Meaning |
|---|---|
200 and content-range: 0-0/5231 | 5,231 rows readable by anyone. Fix now |
200 and content-range: */0 | RLS hides every row from anon, or the table is empty. Confirm with a row count in SQL |
401 with code 42501 | anon has no grant on this table |
404 saying the table could not be found | Not exposed through the Data API |
Then sign in as a test user and repeat with their token to check that user A cannot read user B's rows:
USER_JWT=$(curl -s "$SUPABASE_URL/auth/v1/token?grant_type=password" \
-H "apikey: $SUPABASE_KEY" -H "Content-Type: application/json" \
-d '{"email":"test-a@example.com","password":"..."}' | jq -r .access_token)
curl -s "$SUPABASE_URL/rest/v1/orders?select=id,user_id&limit=5" \
-H "apikey: $SUPABASE_KEY" -H "Authorization: Bearer $USER_JWT"Every user_id that comes back should be user A's.
Step 4: make sure no secret key shipped
Build the production bundle and search it. Secret keys start with sb_secret_. Legacy keys are JWTs, so decode any you find and look at the role:
grep -rlE 'sb_secret_[A-Za-z0-9_-]+' dist .next/static 2>/dev/null && echo "SECRET KEY IN BUNDLE"
grep -rhoE 'eyJ[A-Za-z0-9_-]+\.eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+' dist .next/static 2>/dev/null | sort -u |
while read -r jwt; do
payload=$(printf '%s' "$jwt" | cut -d. -f2 | tr '_-' '/+')
while [ $(( ${#payload} % 4 )) -ne 0 ]; do payload="$payload="; done
printf '%s' "$payload" | base64 -d 2>/dev/null | grep -o '"role":"[a-z_]*"'
done"role":"anon" is expected. "role":"service_role" means the master key is public: rotate it today.
Turn on RLS everywhere, then write policies
Enable RLS on every table in public in one pass:
do $$
declare t record;
begin
for t in
select format('%I.%I', n.nspname, c.relname) as fqn
from pg_class c
join pg_namespace n on n.oid = c.relnamespace
where n.nspname = 'public'
and c.relkind in ('r', 'p')
and not c.relrowsecurity
loop
execute format('alter table %s enable row level security', t.fqn);
raise notice 'RLS enabled on %', t.fqn;
end loop;
end $$;Do it on staging first. With RLS on and no policies, client queries return empty arrays instead of errors, so features break quietly. That is the correct failure: a broken screen is a bug report, a leaked table is a breach notice.
Then, per table, take back the grants your app does not need and write one policy per operation:
revoke all on table public.todos from anon, authenticated;
grant select, insert, update, delete on table public.todos to authenticated;
create policy "todos: read own" on public.todos
for select to authenticated
using ((select auth.uid()) = user_id);
create policy "todos: insert own" on public.todos
for insert to authenticated
with check ((select auth.uid()) = user_id);
create policy "todos: update own" on public.todos
for update to authenticated
using ((select auth.uid()) = user_id)
with check ((select auth.uid()) = user_id);
create policy "todos: delete own" on public.todos
for delete to authenticated
using ((select auth.uid()) = user_id);Following Supabase's guidance: to authenticated skips the policy entirely for signed-out requests; (select auth.uid()) is evaluated once per statement instead of once per row; and with check validates the row being written, so a user cannot insert rows owned by someone else. An update also needs a matching select policy to work.
To stop the next table shipping without RLS, Supabase documents an event trigger that enables it on every new table in public. A compact version, kept in a schema the API does not expose:
create schema if not exists private;
create or replace function private.rls_auto_enable() returns event_trigger
language plpgsql security definer set search_path = pg_catalog as $$
declare
cmd record;
begin
for cmd in
select * from pg_event_trigger_ddl_commands()
where command_tag in ('CREATE TABLE', 'CREATE TABLE AS', 'SELECT INTO')
and object_type in ('table', 'partitioned table')
and schema_name = 'public'
loop
execute format('alter table %s enable row level security', cmd.object_identity);
end loop;
end;
$$;
create event trigger ensure_rls on ddl_command_end
when tag in ('CREATE TABLE', 'CREATE TABLE AS', 'SELECT INTO')
execute function private.rls_auto_enable();It covers only tables created after it is installed.
Policies that look right and are not
to authenticated using (true). It reads as "signed-in users only", but authenticated means "has an account". With open sign-ups that is anyone, and anonymous sign-ins use the authenticated role too.
Policies on a table with RLS off. Policies do nothing until RLS is enabled. The Security Advisor reports this as Policy Exists RLS Disabled.
A column the user can write that grants power. A profiles table with a role or plan column and an "update own row" policy lets every user promote themselves:
update public.profiles set role = 'admin', plan = 'pro' where id = auth.uid();The policy allows it, because the row is theirs. Restrict updatable columns with column privileges and change privileged ones only from server code:
revoke update on public.profiles from authenticated;
grant update (display_name, avatar_url) on public.profiles to authenticated;Authorization from user_metadata. Users can change their own user_metadata from the client. Put roles and tenant membership in app_metadata, which only the server can set, and read it with auth.jwt() -> 'app_metadata'.
Membership tables that let users join anything. This one is common in multi-tenant apps:
create policy "join a team" on public.team_members
for insert to authenticated
with check (user_id = (select auth.uid()));It looks like "users can only add themselves". It also lets any user add themselves to any team, after which every policy that trusts team_members hands them that team's data. Membership changes belong in a server-side invite flow. Our SaaS architecture patterns guide covers tenant isolation models more broadly.
Five ways around RLS you have to close too
1. A secret key in the client
Secret keys (sb_secret_...) and the legacy service_role key act as the service_role role, which has BYPASSRLS. Supabase rejects secret keys sent from a browser by checking the User-Agent header, but an attacker uses curl. The usual leak is an environment variable with a public prefix such as NEXT_PUBLIC_, VITE_ or EXPO_PUBLIC_, which is compiled into the bundle. Run the bundle scan on every release.
2. Views
A view created by postgres runs with its owner's rights by default, so it skips the RLS policies of the tables underneath. With the default grants it returns every row to anon, and because simple views are automatically updatable, it accepts inserts from anon too. On Postgres 15 and later, make views obey the caller's policies:
alter view public.profile_cards set (security_invoker = true);The CI test below lists every view that anon can read and that still runs with owner rights.
3. SECURITY DEFINER functions
Functions in an exposed schema are callable at /rest/v1/rpc/<name>. A SECURITY DEFINER function runs as its owner, and on Supabase that is usually postgres, which bypasses RLS. The CI test below lists the ones anon can call. The trap in the fix: Postgres grants EXECUTE on new functions to PUBLIC, so revoking it from anon alone changes nothing. Revoke from all three:
revoke execute on function public.get_all_profiles() from public, anon, authenticated;Better, keep definer functions in a private schema, each with set search_path = ''.
4. Materialized views
Postgres does not support RLS on materialized views at all; alter ... enable row level security fails on one. If a materialized view sits in public with default grants, every row is readable. Revoke select from anon and authenticated, or move it to a schema the API does not expose.
5. Storage buckets
A public bucket serves any file to anyone with the URL, with no access check on reads, and a broad select policy on storage.objects lets clients list a bucket's contents. Keep user files in private buckets and hand out signed URLs. Find the public ones with select id, name from storage.buckets where public;.
What changes on October 30, 2026 and how to fix 42501 safely
Supabase announced this on April 28, 2026. Until now, a new table in public was automatically granted select, insert, update and delete for anon, authenticated and service_role, so it was reachable through the Data API the moment it existed. The new default removes those grants:
| Date | What happens |
|---|---|
| April 28, 2026 | Opt-in setting available when creating a project |
| May 30, 2026 | Becomes the default for new projects, rolled out gradually |
| October 30, 2026 | Applied to all existing projects |
It affects only new tables created in public after the change reaches your project; existing tables keep their grants. It applies to the Data API (REST, GraphQL and the client libraries), not to direct Postgres connections, and not to the storage, auth, realtime or custom schemas. RLS behavior does not change. A request to a new table without a grant fails like this:
{
"code": "42501",
"message": "permission denied for table your_table",
"hint": "Grant the required privileges to the current role with: GRANT SELECT ON public.your_table TO anon;"
}The hint names the smallest grant that would make that request work, not whether the role should have it. Decide that first, then ship one migration that creates the table, enables RLS, grants the minimum and adds policies:
create table public.invoices (
id bigint generated always as identity primary key,
user_id uuid not null references auth.users (id),
amount_cents integer not null,
created_at timestamptz not null default now()
);
alter table public.invoices enable row level security;
grant select on table public.invoices to authenticated;
grant select, insert, update, delete on table public.invoices to service_role;
create policy "invoices: read own" on public.invoices
for select to authenticated
using ((select auth.uid()) = user_id);Two details trip people up. First, identity columns need no extra grant, but a bigserial column does: inserts fail with permission denied for sequence invoices_id_seq until you grant usage on sequence to the inserting role. Second, service_role needs an explicit grant too, or your own server code hits the same 42501.
What not to do on October 30:
-- re-exposes every table, including the ones you meant to keep private
grant all on all tables in schema public to anon, authenticated;GRANT ALL also hands out TRUNCATE, REFERENCES and TRIGGER, which no client role needs, and Postgres does not apply RLS to TRUNCATE.
To adopt the new behavior on an existing project now, run the statements Supabase publishes:
alter default privileges for role postgres in schema public
revoke select, insert, update, delete on tables from anon, authenticated, service_role;
alter default privileges for role postgres in schema public
revoke usage, select on sequences from anon, authenticated, service_role;If an AI coding tool creates your tables, tell it to write the grant, the enable row level security and the policies together. An agent that follows the 42501 hint literally will grant anon whatever the failing request needed.
Put the check in CI
Supabase's CLI runs pgTAP tests with supabase test db. A test file that fails the build when a table without RLS, an owner-rights view or an anon-callable definer function appears:
-- supabase/tests/rls_guard.test.sql
begin;
select plan(3);
select is_empty(
$$ select format('%I.%I', n.nspname, c.relname)
from pg_class c
join pg_namespace n on n.oid = c.relnamespace
where n.nspname = 'public'
and c.relkind in ('r', 'p')
and not c.relrowsecurity $$,
'every table in public has RLS enabled'
);
select is_empty(
$$ select c.relname
from pg_class c
join pg_namespace n on n.oid = c.relnamespace
where n.nspname = 'public'
and c.relkind = 'v'
and has_table_privilege('anon', c.oid, 'select')
and not exists (select 1 from pg_depend d where d.objid = c.oid and d.deptype = 'e')
and not exists (
select 1 from pg_options_to_table(c.reloptions) o
where o.option_name = 'security_invoker'
and o.option_value in ('true', 'on', '1')
) $$,
'no view readable by anon runs with owner rights'
);
select is_empty(
$$ select p.oid::regprocedure::text
from pg_proc p
join pg_namespace n on n.oid = p.pronamespace
where n.nspname = 'public'
and p.prosecdef
and has_function_privilege('anon', p.oid, 'execute') $$,
'no SECURITY DEFINER function in public is callable by anon'
);
select * from finish();
rollback;And the workflow, following Supabase's documented GitHub Actions setup:
name: db-security
on:
pull_request:
jobs:
rls-guard:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: supabase/setup-cli@v1
with:
version: latest
- run: supabase db start
- run: supabase test dbThis tests the database your migrations build, so it catches the agent-written migration before it merges. It cannot see tables created by hand in the dashboard, so also run the Step 1 inventory against production on a schedule and read the Security Advisor, whose lints include RLS Disabled in Public, Security Definer View and RLS references user metadata. Our API security guide covers the checks that belong next to this one.
What goes wrong in production
RLS goes on, the app goes blank, RLS goes off. Empty arrays look like a bug, so someone disables RLS "temporarily" and it stays off. Ship RLS and its policies in the same migration.
The server uses the secret key for everything. Authorization moves into route handlers, where one missed check exposes everything. Keep user-facing reads on the user's token so policies apply. A request carrying a user's access token runs under that user's policies even when the client was created with a secret key, which confuses people in both directions.
Slow policies on big tables. A bare auth.uid() evaluated per row, or a policy subquery on an unindexed column, turns a fast list into a sequential scan. Wrap the function in select and index what policies filter on.
Staging with production data and RLS off. Preview environments get copies of real data and none of the care. Apply the same migrations everywhere.
A panic GRANT ALL on October 30. A new feature breaks with 42501, someone pastes the broadest grant that makes it work, and every private table is exposed again. Keep a migration template with the minimal grants and use it.
If your app was built fast to prove an idea, our MVP development guide covers what else to harden before real users arrive.
Frequently Asked Questions
Is it safe to expose the Supabase anon key?
Yes, it is designed to be public. The anon key, and its replacement the publishable key, only lets a caller act as the anon role, or as the authenticated role once a user signs in, so it reaches exactly what your grants and Row Level Security policies allow. That makes it safe only if every exposed table has RLS with correct policies. The secret and service_role keys must never leave your server.
How do I check whether RLS is enabled on every table?
Run a catalog query in the SQL Editor that lists tables in the public schema whose relrowsecurity flag is false. Each one is readable and writable by client roles that hold grants on it. The Security Advisor flags the same problem as RLS Disabled in Public, and a pgTAP test in CI stops new ones shipping.
Does enabling RLS with no policies block all access?
Yes, for client roles. With RLS enabled and no policies, Postgres applies a default deny, so requests made with the publishable or anon key, signed in or not, see no rows and cannot write. The table owner and roles with BYPASSRLS, which on Supabase includes the service_role behind secret keys, still have full access.
What changes for Supabase tables on October 30, 2026?
From October 30, 2026, Supabase stops automatically granting the anon, authenticated and service_role roles access to new tables created in the public schema of existing projects. Existing tables keep their grants, and RLS behaves exactly as before. A new table stays invisible to the Data API until you grant access explicitly, and requests to it fail with error 42501.
How do I fix the 42501 permission denied error without GRANT ALL?
Grant only the privileges each role needs on that one table, in the same migration that enables RLS and creates the policies. Grant select to anon only if signed-out visitors should read the table, and write privileges to authenticated only where policies restrict them to the right rows. Avoid GRANT ALL ON ALL TABLES: it re-exposes every table and hands out TRUNCATE, which RLS does not cover.
Do views and database functions bypass RLS?
They can. A view created by the postgres role runs with its owner's privileges by default, so it returns rows the underlying table's policies would hide, unless you set security_invoker = true on Postgres 15 or later. A SECURITY DEFINER function runs as its owner and skips RLS when that owner can, so revoke EXECUTE from public, anon and authenticated, or keep such functions out of exposed schemas.
Built it with AI and not sure it is safe?
We take apps that were built fast, often with AI tools, and make them safe to run with real users and real data. We work through checks like these against your actual project, fix what we find in migrations you can review, and stay on the pager for what we ship.
