DEV Community

Cenk KURTOĞLU
Cenk KURTOĞLU

Posted on

Supabase's Default Privileges Expose Every New Table. Here's the Self-Healing Fix.

If you dump a Supabase schema and replay it anywhere — a new project, a template, a customer deployment — your dump quietly re-creates the most dangerous default in the platform: GRANT ALL ON TABLES TO anon. Every table the dump creates after that line is born world-readable to the anon key that ships in your frontend bundle.

Most teams handle this with a code review checklist: "remember to revoke anon on new tables." One open-source CRM with 4,500+ stars handles it better — with a catalog-driven function that finds unprotected tenant tables and locks them automatically, plus a unit test that fails the build if the protection ever stops running last.

This is the anatomy of that trap, and the self-healing pattern that defeats it.

The trap: your own dump re-arms it

Run pg_dump on any Supabase project and look at what comes out near the top:

ALTER DEFAULT PRIVILEGES FOR ROLE "postgres" IN SCHEMA "public"
  GRANT ALL ON TABLES TO "anon";
ALTER DEFAULT PRIVILEGES FOR ROLE "postgres" IN SCHEMA "public"
  GRANT ALL ON TABLES TO "authenticated";
Enter fullscreen mode Exit fullscreen mode

pg_dump faithfully re-emits the platform's default-privilege entries into pg_default_acl. This is not pg_dump being careless — it's recording the project's actual state. But the effect when you replay that dump is that every CREATE TABLE that follows it — every table in your baseline, every appendix block, every template you ship — inherits the grant before your RLS policies even exist.

The sequence that burns teams:

dump re-emits ALTER DEFAULT PRIVILEGES ... TO anon
  → new table created in the dump
    → table is born granted to anon
      → RLS not yet enabled (or enabled with a permissive policy)
        → PostgREST exposes it with the anon key
Enter fullscreen mode Exit fullscreen mode

RLS being "enabled" later helps — but only if the policies are right. The grant underneath means the table is reachable the moment it exists, and reachable forever if the revoke is forgotten.

Why "remember to revoke" fails

I scan public Supabase repos for exactly this class of issue (results from 100 repos: ~25% with permissive RLS, most of it this pattern). The failure is never ignorance — plenty of teams know anon should be revoked. The failure is remembering, per table, across every migration and every appendix block, forever. One forgotten revoke on one table with customer data is the entire breach.

The naive mitigation — put the ALTER DEFAULT PRIVILEGES revoke at the top of your dump — has its own trap: it changes what future tables inherit, but tables created before your revoke (or by other roles) keep their grants. And the grant to PUBLIC that Postgres gives functions at creation survives revoke from anon — you need both revokes. Miss either source and the surface stays open.

The self-healing pattern

DeskcommCRM — an open-source AI sales CRM (MIT) with a Supabase baseline of 200+ tables — runs this function in the same transaction that provisions new module tables:

create or replace function public.fn_proteger_tabelas_de_organizacao()
returns void language plpgsql set search_path = public as $f$
declare r record;
begin
  for r in
    select c.relname from pg_class c
    join pg_namespace n on n.oid = c.relnamespace
    where n.nspname = 'public'
      and c.relkind = 'r'
      and not c.relrowsecurity                    -- RLS still off
      and exists (                                -- and it's a tenant table
        select 1 from pg_attribute a
        where a.attrelid = c.oid
          and a.attname = 'organization_id'
          and a.attnum > 0 and not a.attisdropped)
    order by c.relname
  loop
    execute format('alter table public.%I enable row level security', r.relname);
    execute format('revoke all on public.%I from anon', r.relname);
    execute format('drop policy if exists tenant_isolation_%s_all on public.%I', r.relname, r.relname);
    execute format(
      'create policy tenant_isolation_%s_all on public.%I for all
       using (organization_id in (select * from public.fn_user_org_ids()))
       with check (organization_id in (select * from public.fn_user_org_ids()))',
      r.relname, r.relname);
  end loop;
end $f$;
Enter fullscreen mode Exit fullscreen mode

What it does, line by line:

  1. Queries the catalog, not a list. It reads pg_class for every table in public where RLS is still off and the table carries an organization_id column — their multi-tenancy marker. If a module adds a tenant table and forgets RLS, this function finds it anyway. The protection doesn't depend on anyone remembering the table exists.
  2. Enables RLS, revokes anon, installs a tenant-isolation policy — all three layers, atomically, per table. The policy keys on fn_user_org_ids() so rows are only visible to members of the owning organization, with with check enforcing it on writes too.
  3. Runs as the last step of provisioning, in the same transaction as table creation — the window where the table exists but isn't protected never opens for a concurrent reader.

Their baseline's comments (in Portuguese) state the doctrine outright: new tables are born granted ("tabela nova nasce concedida") and the sweep is what cures them — then they go one step further.

The test that keeps the healer healthy

A sweep that stops running is indistinguishable from no sweep. So the repo includes a unit test with a pointed name: varredura-anon-e-o-ultimo-bloco — "the anon sweep is the last block." It fails the build if the sweep ever stops being the final block of the baseline, because any appendix block added after the sweep would be born granted and never cured.

That's the full maturity ladder in one test:

Level Strategy Fails when
1 Hope + code review a human forgets once
2 Explicit revokes per table one table is added without one
3 Catalog sweep at provision time the sweep stops running last
4 Sweep + test enforcing the sweep's position (you have to break the test)

The one-query check for your own project

Whether or not you adopt the pattern, run this today:

select c.relname,
       exists(select 1 from pg_class x where x.oid = c.oid and x.relrowsecurity) as rls_on,
       has_table_privilege('anon', c.oid, 'SELECT') as anon_can_read
from pg_class c
join pg_namespace n on n.oid = c.relnamespace
where n.nspname = 'public' and c.relkind = 'r'
  and has_table_privilege('anon', c.oid, 'SELECT');
Enter fullscreen mode Exit fullscreen mode

Every row returned is a table the anon key in your bundle can read. For multi-tenant tables the row above should not exist at all; if it does, the sweep pattern above is your fix template.

What October 30 changes — and what it doesn't

Supabase's October 30, 2026 change stops the platform from auto-granting anon/authenticated access to new tables in existing projects. That closes the trap at the platform level for platform defaults — but it does not touch two things:

  1. Your own dumps and templates. If your baseline or install template re-emits the old ALTER DEFAULT PRIVILEGES ... GRANT ALL TO anon (as pg_dump does), your deployments re-arm the trap regardless of what the platform does. The DeskcommCRM approach — explicit management plus an automated, tested sweep — is immune precisely because it never trusted the platform default in either direction.
  2. Tables that already exist. The change affects new tables; the grants already sitting in your catalog stay until you revoke them.

The general lesson survives any platform change: don't rely on defaults, old or new. Make your protections explicit, make them catalog-driven, and make a test prove they run.


References: the DeskcommCRM baseline (MIT, Rafael Melgaço) for the sweep pattern and its enforcement test; my 100-repo scan data for the prevalence numbers; the Supabase docs on role privileges for the default-grant behavior.

Top comments (0)