Supabase's Oct 30 change: new tables need explicit grants
Since October 30, 2026, Supabase no longer grants the API roles access to new tables in the public schema. Tables you already had keep working. The next table you create stays out of the Data API until you grant it, and the quickest way to make that error go away can expose every row in it.
What changed
A Supabase project used to come with default privileges on public: each new table was granted select, insert, update and delete for the anon, authenticated and service_role roles the moment it was created, and new sequences got usage and select. That is why a new table showed up in supabase-js straight away.
Supabase has removed those defaults. New projects got the new behavior from May 30, 2026, in a gradual rollout, and Supabase set October 30 as the date for all existing projects. A new table in public now exists in the database, but the API roles can't reach it until something grants them access.
What it doesn't change:
- Existing tables keep their current grants and stay reachable.
- Row-level security works as before. Grants decide whether a role can use a table at all; RLS decides which rows it sees.
- Direct Postgres connections, such as psql or an ORM with a connection string, are not affected. The change is about the Data API: PostgREST, GraphQL and the client libraries that call them.
- Tables in
storage,auth,realtimeand custom schemas keep their grants and defaults.
One detail catches people out. Default privileges apply when an object is created and never to objects that already exist. A table that a migration drops and creates again is a new table, so it comes back without the grants it had.
The error you'll see
Query a new table through the API and PostgREST refuses with Postgres error 42501:
{
"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;"
}
PostgREST sends it with HTTP status 401 for anonymous requests and 403 for signed-in ones. The hint even names the statement to run.
The quick fix that leaks
The hint makes the fastest fix look obvious: run the grant, and the error is gone.
grant select, insert, update, delete
on public.invoices
to anon, authenticated;
If that is all you run, the table is now readable and writable by anyone with your project's anon key, and the anon key ships in your front end. Without RLS on the table there are no row rules to apply, so every caller gets every row. Supabase's own troubleshooting page for 42501 says it plainly: granting privileges allows access to your table through the Data API, so enable RLS and write policies.
The order matters more than it used to. Under the old defaults a table was exposed the moment it was created, and teams turned RLS on right after. Now nothing is exposed until the grant runs. The grant is the moment of exposure, so it belongs in the same migration as RLS and the policies.
A quieter version of the same mistake: RLS is on, but the policy only checks that someone is signed in.
create policy "read invoices"
on public.invoices for select
to authenticated
using (auth.uid() is not null);
That passes every test written with a single user. It also gives every signed-in user every company's invoices. It is the policy in the demo on our home page.
The right fix
Supabase's guidance is to treat three steps as a unit: grant, enable RLS, add policies. If the grant is missing, Postgres rejects the query before RLS comes into play. If RLS is missing, the grant exposes everything.
For a table that holds more than one company's data, the policy has to check which company the user belongs to, not only that they are signed in:
-- 1. Grant only what each role needs.
-- No anon: invoices aren't public.
grant select, insert, update, delete
on public.invoices to authenticated;
-- 2. Turn on row-level security.
alter table public.invoices
enable row level security;
-- 3. Policies scoped to the company.
create policy "read own invoices"
on public.invoices for select
to authenticated
using (org_id in (
select org_id
from public.memberships
where user_id = auth.uid()
));
create policy "add own invoices"
on public.invoices for insert
to authenticated
with check (org_id in (
select org_id
from public.memberships
where user_id = auth.uid()
));
Writes need the same company check in with check, the expression Postgres tests new and changed rows against. An insert policy with with check (true), or one that only checks auth.uid() is not null, lets any signed-in user write rows into another company's data. Updates and deletes need their own policies, built the same way.
Those policies read public.memberships, and Postgres runs policy expressions with the rights of the user running the query. So authenticated needs select on memberships too, and memberships needs RLS with a policy that shows each user only their own rows, such as using (user_id = auth.uid()). If memberships was created after October 30 and never granted, every query on invoices fails with 42501.
Sequences lost their default grants too. If inserts through the API fail on a serial column, the role needs usage on that column's sequence, because nextval requires it.
Your old tables aren't fixed by this
The change only touches tables created after it. Every table that existed before October 30 still has the old grants for anon, authenticated and service_role, including any table where RLS was never turned on. Those are exactly as exposed as they were.
This query lists the tables in public, whether RLS is on, and whether anon and authenticated can read them:
select c.relname as table_name,
c.relrowsecurity as rls_enabled,
has_any_column_privilege(
'anon', c.oid, 'select'
) as anon_can_select,
has_any_column_privilege(
'authenticated',
c.oid, 'select'
) as authenticated_can_select
from pg_class c
join pg_namespace n
on n.oid = c.relnamespace
where n.nspname = 'public'
and c.relkind in ('r', 'p')
order by rls_enabled, table_name;
A row with rls_enabled false and anon_can_select true is a table where anyone with your anon key can read every row. With authenticated_can_select true instead, it's every signed-in user, whichever company they belong to. Supabase points to two places in the dashboard for a second check: the Data API exposure badge in the Table Editor, and the Security Advisor, which lists the tables worth reviewing.
Then look at the policies themselves:
select tablename, policyname, cmd,
roles, qual, with_check
from pg_policies
where schemaname = 'public'
order by tablename, cmd;
On any table that holds more than one company's data, look for qual or with_check expressions like (auth.uid() IS NOT NULL) or true.
Functions need their own look. Supabase's change revokes default grants on tables and sequences only, and Postgres gives EXECUTE on every new function to PUBLIC, which includes the API roles. A security definer function runs with its owner's privileges, and table owners normally bypass row security. So a security definer function in public that doesn't check the caller's company can still be called through the API, before October 30 and after it.
For functions the API shouldn't call, revoke execute from public, and from anon and authenticated if they hold it directly. The rest have to check the caller's company inside the function.
Sources
- Supabase changelog: Tables not exposed to Data and GraphQL API automatically (April 28, 2026)
- Supabase docs: Database API 42501 errors
- PostgREST docs: Errors
- PostgreSQL docs: ALTER DEFAULT PRIVILEGES
- PostgreSQL docs: Privileges
- PostgreSQL docs: CREATE FUNCTION
- PostgreSQL docs: CREATE POLICY
- PostgreSQL docs: Row security policies
- PostgreSQL docs: Sequence manipulation functions
- PostgreSQL docs: System information functions