On October 30, 2026 Supabase stops auto-granting new objects in the public schema to anon, authenticated and service_role on every existing project. The five-week notice email went out on September 23, and since then the questions have been the same ones over and over: will my app break?, which tables?, what do I run?
Short answers: your running app does not break, the tables that get hurt are the ones you haven't created yet (or the ones you re-create from migrations), and the SQL you are most likely to run is the one that makes things worse.
This post covers what actually changes, what doesn't, the fix that quietly reopens every table without RLS, and how to write migrations that survive October 30 without exposing anything. Every Postgres behaviour below I reproduced on a real Postgres (18.3); the Supabase specifics are quoted from the changelog and from Supabase engineers in discussion #45329.
TL;DR
- Existing tables keep their grants. Nothing that works today stops working on Oct 30. Supabase engineers confirmed this in the discussion thread.
-
New tables, views and sequences created in
publicafter Oct 30 are born with no grants:supabase-jsgets42501 permission denied. That includes server code using theservice_rolekey. -
Replaying your migrations on a new project, a preview branch or
supabase db resethits this for every table your migrations create without an explicitGRANT. - The changelog's own rollback snippet is
grant ... on all tables in schema public to anon, authenticated, service_role. On a table without RLS, that grant publishes every row to anyone holding your anon key. -
Functions are not covered by the change. Postgres grants
EXECUTEtoPUBLICby default,anoninherits it, and the per-schema revoke in the docs has no effect on that. - The durable fix: in the same migration that creates the object, write grants per table, per role, mirroring your RLS policies. Then add a CI check so the next migration can't regress.
What changes, exactly
Until now, a Supabase project shipped with default privileges that granted select, insert, update, delete on every new table in public to the three API roles. Create a table and it was reachable through PostgREST (and GraphQL) straight away, protected only by RLS, if you remembered to turn it on.
The change removes those default privileges. The official "opt in early" SQL is exactly this:
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;
A few things to note:
-
"Tables" includes views. In Postgres, default privileges
on tablesapply to all relations, so a new view is born without grants too. -
Sequences are separate. A table grant does not cover the sequence behind a
serialcolumn (more on that below). -
service_roleloses the automatic grant as well.service_rolebypasses RLS, not privileges. If your backend or Edge Function usessupabase-jswith the service key against a new table, it gets the same42501. Only direct Postgres connections (psql, an ORM with a connection string) are unaffected, because they don't go through the Data API roles. -
Not affected:
storage,auth,realtimeand custom schemas keep their current defaults (changelog FAQ).
What you'll see
A missing grant is not a silent empty result. PostgREST answers with:
{
"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;"
}
over HTTP 401 for anon requests and 403 for authenticated ones. A useful rule of thumb when debugging:
| Symptom | Meaning |
|---|---|
42501 permission denied for table |
The role has no grant. RLS was never even evaluated. |
42501 permission denied for sequence |
The table grant is there, the serial sequence grant isn't. |
[] (empty array, 200) |
Grant is fine, RLS returns no rows for this role. |
PGRST205 table not found in schema cache |
PostgREST hasn't reloaded yet: notify pgrst, 'reload schema';
|
What does NOT break
This is the part the email made scarier than it is. From the changelog: "Existing tables are not affected in your project, they keep their current grants and stay reachable." In the discussion, a user asked whether existing tables on existing projects are "guaranteed to retain their current grants and will never be silently revoked by the October 30 rollout", and a Supabase engineer answered: "Both are correct."
So if your project is live and you never create another table, nothing happens on October 30.
That is also the uncomfortable part: every table that is over-exposed today stays over-exposed. The change only protects what you create next.
Where it actually bites
-
The next migration that creates a table. It deploys fine, and the first request from the app fails with
42501. With AI agents generating migrations, this tends to show up as "the feature works locally and fails in production". -
Replaying migrations anywhere fresh. A new project per customer, a staging environment, a preview branch, a teammate's
supabase db reset. Your old migrations never neededGRANTstatements, so on a project without the old defaults every table they create is unreachable. As one user put it in the thread: "any new instance of that app that you deploy will fail to work because the old migration scripts didn't grant the required privileges." -
Local and cloud disagree. CLI v2.102.0 added
[api] auto_expose_new_tables = falsetoconfig.tomlsodb resetmimics the new cloud behaviour, but it is a temporary aid scheduled for removal on 2026-10-30, and at least one user reported branches failing to clone with'api' has invalid keys: auto_expose_new_tables. If you leave it unset, local keeps auto-exposing and hides the problem until production.
The fix that reopens everything
When a table suddenly answers 42501, the fastest thing that makes the error go away is a bulk grant. It is literally what the changelog FAQ gives as the rollback:
grant select, insert, update, delete on all tables in schema public to anon, authenticated, service_role;
grant usage, select on all sequences in schema public to anon, authenticated, service_role;
And in the discussion, a Supabase engineer suggested the same bulk grant as a one-off migration for existing tables, plus grant execute on all functions in schema public to anon, authenticated, service_role. To be fair, that reply also says per-object grants are "the better answer for most projects". But the bulk version is the one that gets copy-pasted, and the email template's example grants select to anon too.
The bulk grant is only safe if every table in public has RLS enabled and correct policies. On any table where RLS is off, it means this:
create table public.secrets (email text);
insert into public.secrets values ('a@b.c');
grant select, insert, update, delete on all tables in schema public
to anon, authenticated, service_role;
set role anon;
select * from public.secrets;
-- email
-- -------
-- a@b.c
Anyone holding the anon key (it's in your JS bundle, by design) can read, insert, update and delete every row through /rest/v1/secrets. RLS off plus a grant is exactly the pattern behind the TechCrunch report from September 25: UpGuard found around 16,000 Supabase databases exposing personal data. The October 30 change is Supabase trying to make that harder by default. A bulk grant written to silence an error undoes it.
The same goes for the "restore the old defaults" option (alter default privileges ... grant ... to anon, authenticated, service_role): it puts every future table back on the API before anyone has written a policy for it.
Writing migrations that survive Oct 30
The rule the changelog itself states is "Treat these three steps as a unit": grant, enable RLS, add policies, in the same migration that creates the table. The part I'd add: grant each role only what its policies actually use. A grant to a role with no matching policy just widens the surface for the day someone disables RLS.
Tables
create table public.orders (
id bigint generated always as identity primary key,
user_id uuid not null references auth.users (id) default auth.uid(),
total_cents int not null,
created_at timestamptz not null default now()
);
alter table public.orders enable row level security;
create policy "own orders: read" on public.orders
for select to authenticated using ((select auth.uid()) = user_id);
create policy "own orders: create" on public.orders
for insert to authenticated with check ((select auth.uid()) = user_id);
-- grants mirror the policies: authenticated can select/insert, anon gets nothing
grant select, insert on table public.orders to authenticated;
-- backend / Edge Functions using the service key
grant select, insert, update, delete on table public.orders to service_role;
A few decisions this forces you to make explicitly, which is the point:
-
anononly where a policy is writtento anon(a public catalogue, a waitlist insert). If no policy mentionsanon, don't grant it. -
service_roleonly on tables your server code touches through the Data API. A table only reached through a direct connection doesn't need it. -
Tables that should never be on the API (audit logs, internal queues): no grants at all, or an explicit
revoke all on table public.x from anon, authenticated, service_role;.
Sequences: serial vs identity
This one catches people because it only fails on insert, and only for the API roles:
create table public.s (id serial primary key, v text);
create table public.i (id bigint generated always as identity primary key, v text);
grant select, insert on public.s, public.i to authenticated;
set role authenticated;
insert into public.s (v) values ('a');
-- ERROR: permission denied for sequence s_id_seq
insert into public.i (v) values ('a');
-- ok, id = 1
A serial column is a plain sequence plus a nextval() default, so the inserting role needs usage on the sequence:
grant usage, select on sequence public.s_id_seq to authenticated, service_role;
An identity column's sequence is internal to the table and doesn't need a separate grant. For new tables, generated always as identity is one less thing to forget. For existing serial tables that you replay from migrations, add the sequence grant.
Views
Views are born without grants too, and they have a second trap: by default a view runs with its owner's privileges, so it bypasses the RLS of the tables it reads. If you grant a view to anon or authenticated, make it respect the caller's RLS (Postgres 15+):
create view public.order_totals with (security_invoker = on) as
select user_id, sum(total_cents) as total_cents from public.orders group by user_id;
grant select on public.order_totals to authenticated;
Materialized views can't have RLS at all: keep them off anon/authenticated and expose them through a function or a server route instead.
Functions: the part Oct 30 doesn't touch
Postgres grants EXECUTE on every new function to PUBLIC, and anon/authenticated are members of PUBLIC. So every function you create in public is callable as /rest/v1/rpc/<name> with the anon key unless you revoke it. With security definer, it runs with its owner's rights and skips RLS entirely.
The Securing your API docs suggest alter default privileges for role postgres in schema public revoke execute on functions from public;. That statement doesn't have the documented effect (supabase#49338, and a reproduction in the discussion). The reason is a Postgres rule: per-schema default privileges can only add to the global defaults, never remove from them. The built-in PUBLIC execute is a global default, so a revoke scoped in schema public changes nothing:
alter default privileges for role postgres in schema public
revoke execute on functions from public;
create function public.f1() returns int language sql as 'select 1';
select has_function_privilege('anon', 'public.f1()', 'execute'); -- true
alter default privileges for role postgres
revoke execute on functions from public; -- no "in schema"
create function public.f2() returns int language sql as 'select 2';
select has_function_privilege('anon', 'public.f2()', 'execute'); -- false
The global form works on plain Postgres, but it applies to every schema, and I haven't verified it against the extra default ACLs a hosted Supabase project carries. The approach that works everywhere is explicit and per function:
create function public.admin_stats() returns json
language sql security definer set search_path = '' as $$
select json_build_object('users', (select count(*) from auth.users));
$$;
revoke execute on function public.admin_stats() from public, anon;
grant execute on function public.admin_stats() to service_role;
Two details:
-
revoke ... from anonalone is not enough:anonstill gets it throughPUBLIC. Revoke from both. - Helper functions used inside RLS policies (
is_org_member(org_id)and the like) needexecutefor the role the policy applies to, usuallyauthenticated. Revokeanon, keepauthenticated.
And always pin search_path on security definer functions.
Audit what's already exposed
Since existing tables keep their grants, run these once in the SQL editor to see what is on the API today.
Tables reachable by anon or authenticated with RLS off (this is the dangerous list):
select c.oid::regclass as relation,
has_table_privilege('anon', c.oid, 'select,insert,update,delete') as anon,
has_table_privilege('authenticated', c.oid, 'select,insert,update,delete') as authenticated
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
and (has_table_privilege('anon', c.oid, 'select,insert,update,delete')
or has_table_privilege('authenticated', c.oid, 'select,insert,update,delete'));
Functions anon can call (look hard at every security_definer = true):
select p.oid::regprocedure as function, p.prosecdef as security_definer
from pg_proc p
join pg_namespace n on n.oid = p.pronamespace
where n.nspname = 'public'
and has_function_privilege('anon', p.oid, 'execute')
order by p.prosecdef desc;
Views granted to anon that don't enforce the caller's RLS:
select c.oid::regclass as view
from pg_class c
join pg_namespace n on n.oid = c.relnamespace
where n.nspname = 'public'
and c.relkind = 'v'
and coalesce(array_to_string(c.reloptions, ','), '') !~ 'security_invoker=(on|true|1|yes)'
and has_table_privilege('anon', c.oid, 'select');
The Security Advisor and the "Data API exposure" badge in the Table Editor cover part of this too.
Make local match production now
Don't wait for October 30 to discover which migrations lack grants. Either set this in supabase/config.toml (CLI ≥ 2.102.0, remember it's temporary):
[api]
auto_expose_new_tables = false
or, more durable, put the same revoke the dashboard runs into an early migration so every environment behaves like post-Oct-30 production:
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;
Then supabase db reset and click through the app. Every 42501 is a grant you were silently relying on.
Catch it in CI
Remembering all of the above on every PR is the part that doesn't scale, especially when an agent writes the migration. I wrote an open-source (MIT) linter that replays your supabase/migrations SQL in order, with no database and no credentials, and flags:
- tables/views with no grant (unreachable after Oct 30, and already on any fresh project or branch);
- tables granted to
anon/authenticatedwith RLS off; - bulk
grant ... on all tables ... to anonandalter default privileges ... to anon; -
security definerfunctions callable byanon, including throughPUBLIC; - views without
security_invoker,serialsequences missingusage,security definerwithoutsearch_path.
For each finding it proposes the least-privilege grant based on your policies. Drop this into .github/workflows/supabase-grants.yml:
name: Supabase grants lint
on:
pull_request:
paths: ['supabase/**']
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: Perufitlife/supabase-security-skill@main
with:
mode: migrations # reads supabase/migrations, no project ref, no token
fail-on: high # or: critical
You get inline annotations on the offending migration lines, a job summary, and a proposed migration uploaded as an artifact. To run it locally first:
npx -y github:Perufitlife/supabase-security-skill migrations
Honest limits: it's a replay of your SQL, not a Postgres, so it skips DO blocks, dynamic EXECUTE and anything created from the dashboard. It also can't know which tables you meant to be public: when it proposes a grant, read it. Mark intentional exceptions with -- supabase-security: ignore above the statement. Issues with the SQL that fools it are welcome: github.com/Perufitlife/supabase-security-skill.
Checklist before October 30
- Run the three audit queries above. Fix anything with RLS off that
anoncan reach today. That exposure doesn't depend on the date. - Make local behave like post-Oct-30 production (
config.tomlor an early revoke migration) anddb reset. - Add a migration with per-table grants for everything your app uses, mirroring your policies. Don't use
on all tables. - Add
usageon sequences behindserialcolumns the API inserts into. -
revoke execute ... from public, anonon every function that isn't meant to be a public endpoint. - Put a check in CI so the next migration doesn't regress.
- Update your AI agent's instructions (or the Supabase agent skill): grants + RLS + policies in the same migration, never a bulk grant.
If you'd rather have someone do this for you: I apply least-privilege grants, fix the RLS gaps and verify them on a branch, delivered as a pull request. There's also a free check that takes a public repo URL and emails you the report: supabase-security Oct 30.
Top comments (0)