DEV Community

Cover image for Supabase Stops Auto-Exposing New Tables on Oct 30: The Checklist to Run This Week
Chaminade Dondah ADJOLOU
Chaminade Dondah ADJOLOU

Posted on

Supabase Stops Auto-Exposing New Tables on Oct 30: The Checklist to Run This Week

Supabase is a popular backend for hackathons, side projects and production apps alike, and for good reason. You create a table, call it from supabase-js, and you have a working app before lunch. That convenience came from a default: every new table in the public schema was automatically reachable through the Data API.

That default is going away. Supabase announced it in its changelog in April, it's already the default for new projects, and on October 30, 2026 it will be applied to all existing projects. That's about ten days from now.

Whether you have a weekend hackathon build you want to keep alive, a side project you touch once a month, or an app with real users, there are two ways this can hurt you:

  • It breaks: a tutorial, starter repo or AI-generated migration written for the old behavior creates a table and your client gets 42501 permission denied.
  • It leaks: you "fix" that error the fast way, or you keep adding tables to an older project where new tables are still auto-granted, and you end up with a table that anyone holding your public key can read, update and delete.

This post explains exactly what's changing (from Supabase's own changelog), the mental model you need, and a checklist with SQL you can paste into your migrations this week, before the change reaches your project.

What exactly is changing

From the official changelog entry, "Breaking Change: Tables not exposed to Data and GraphQL API automatically":

Date What happens
April 28, 2026 Opt-in setting available at project creation
May 30, 2026 New behavior becomes the default for new projects (gradual rollout over a few weeks)
October 30, 2026 New behavior applied to all existing projects

Before: when you created a project with default settings, select, insert, update and delete were granted on every table in public to the anon, authenticated and service_role roles. Every table was reachable via the Data API on creation.

After: new tables in public "require an explicit opt-in (via a Postgres grant) before the Data API can see them."

Four clarifications from the same page that matter a lot:

  1. Existing tables are not affected. They "keep their current grants and stay reachable." Your running app doesn't go dark on October 30.
  2. It only concerns the Data API (the auto-generated REST and GraphQL layer that supabase-js and the other client libraries call). If your app talks to Postgres over a direct connection — psql, an ORM, an app server with a connection string — "this change will not affect you."
  3. RLS behavior is unchanged. Grants and Row Level Security are separate layers.
  4. Only the public schema is concerned. Per the changelog FAQ, tables in storage, auth, realtime and custom schemas keep their current grants and defaults.

When a grant is missing, PostgREST returns an explicit error instead of failing silently:

{
  "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;"
}
Enter fullscreen mode Exit fullscreen mode

Timeline of the rollout: April 28 opt-in, May 30 default for new projects, October 30 enforced on existing projects
The rollout dates come from Supabase's changelog. If your project was created before the new default, October 30 is your date.

Supabase also says it is emailing owners and admins of active projects (a five-week notice went out on September 23, and a final notice is scheduled for October 23), and that the Security Advisor flags affected tables with remediation SQL. If you got one of those emails and filed it away, this is the week to open it.

Who gets caught by this

Code written fast, by several people at once, frequently with an AI assistant, and without a review step is exactly the kind of code this change targets. Supabase's changelog names that context as the reason for the change: today, "agents, CLI scripts, and AI platforms create tables too, and many of those operations do not have a human reviewing the diff."

Hackathon projects fit that description perfectly, but so do side projects and plenty of production apps. Here are the three scenarios I'd expect to see around October 30:

Scenario A — You start a brand-new project. New projects have had the new behavior by default since the May 30 rollout. Any tutorial, video or starter template written before that assumes your tables are reachable right after create table. They won't be. You'll hit 42501 and lose time.

Scenario B — You keep building on an older project. Until October 30, new tables on that project still receive the old default grants. Create a table from a migration or a script without enabling RLS, and it is immediately readable and writable by the anon role. Then, after October 30, the next table you add to the same project will behave differently from the ones you created last week, which is a confusing way to discover the change. This is the most common case for hackathon builds that turned into side projects, and for production apps that have been running for a while.

Scenario C — You rebuild from migrations later. You spin up a fresh project, a preview branch, or reset your local database. Tables created by migrations without explicit grants will not be reachable through the Data API anymore. The app that worked in the demo or in production "suddenly" breaks in the new environment.

The fix for all three is the same: make access an explicit, written decision in your migrations.

The mental model: GRANT and RLS are two different doors

This is the part I'd want every teammate to understand before touching the database.

  • GRANT decides whether a role can touch a table at all.
  • RLS decides which rows that role sees once it can.

And you need to know who the roles are:

  • anon is what requests run as when nobody is signed in. Your anon (or publishable) key ships in your frontend bundle. Anyone can extract it with the browser's dev tools. Whatever anon can do, the whole internet can do.
  • authenticated is what signed-in users run as. If anyone can sign up for your app, this is almost as public as anon.
  • service_role is for server-side code only. It bypasses RLS. It must never reach a browser or a mobile app bundle.

Put grants and RLS together and you get four situations:

Matrix of GRANT and RLS combinations: GRANT without RLS exposes every row; GRANT with RLS and no policy returns nothing; GRANT with RLS and a policy returns allowed rows; no GRANT returns 42501
Red is the leak. Blue is what new tables look like after the change.

Only the red row is dangerous, and it's an easy one to land in. A table created through plain SQL starts with RLS off (that's the Postgres default). Under the old default grants, that meant: readable and writable by anon from the moment it existed.

The October 30 change moves new tables to the blue row. That's safer. The danger is in how people get out of the blue row.

Trap 1: "fixing" 42501 with GRANT ALL

You create a table, the app throws 42501, you search the error (or ask your AI assistant), and you find something like this:

-- Don't do this on its own
grant all on table public.notes to anon, authenticated, service_role;
Enter fullscreen mode Exit fullscreen mode

The error disappears. If RLS isn't enabled on that table, you have just moved it straight to the red row. The DEV author x3blank made this point well in a recent DEV post, and it's worth repeating: the most common quick fix for this error re-opens exactly the hole the change closes.

Even the hint in the official error message suggests granting SELECT to anon. That's correct for a public catalog. It's wrong for a table of user submissions. The hint tells you what is missing, not whether that role should have it.

The same caution applies to the changelog's own rollback FAQ, which shows how to bulk-grant select, insert, update, delete on all tables in schema public if an early opt-in breaks your app. That's a legitimate emergency rollback, but it puts every table back in reach of anon, so check RLS on each of them afterwards.

Trap 2: before Oct 30, adding a GRANT doesn't remove the default ones

If you're working on an older project (Scenario B), create table still gives anon full CRUD until the change reaches your project. Adding grant select ... to authenticated doesn't take anything away from anon, because GRANT only adds privileges.

So the order that's safe both before and after October 30 is: revoke first, then enable RLS, then write policies, then grant only what's needed. The revoke is a harmless no-op once the new default applies.

Six steps in one migration: create table, revoke defaults, enable RLS, write policies, grant only what's needed, test with the anon key
The same order in every migration. Step 6 is the one that tells you the truth.

The migration template to paste into your repo

Let's say you're building a small app where signed-in users file reports, and there's a public list of categories anyone can read. Two tables, two very different access needs.

A public, read-only table

create table public.categories (
  id bigint generated always as identity primary key,
  name text not null unique,
  created_at timestamptz not null default now()
);

-- 1. Remove anything that was auto-granted (no-op on projects with the new default)
revoke all on table public.categories from public, anon, authenticated;

-- 2. RLS on, always
alter table public.categories enable row level security;

-- 3. Policy: anyone may read
create policy "categories are public"
  on public.categories for select
  to anon, authenticated
  using (true);

-- 4. Minimal grants: read for clients, full access for server code
grant select on table public.categories to anon, authenticated;
grant select, insert, update, delete on table public.categories to service_role;
Enter fullscreen mode Exit fullscreen mode

A private, owner-only table

create table public.reports (
  id bigint generated always as identity primary key,
  user_id uuid not null default auth.uid() references auth.users (id),
  body text not null check (char_length(body) <= 2000),
  created_at timestamptz not null default now()
);

revoke all on table public.reports from public, anon, authenticated;

alter table public.reports enable row level security;

create policy "users read their own reports"
  on public.reports for select
  to authenticated
  using (user_id = (select auth.uid()));

create policy "users create their own reports"
  on public.reports for insert
  to authenticated
  with check (user_id = (select auth.uid()));

-- No grant to anon at all. Signed-in users can read and insert, nothing else.
grant select, insert on table public.reports to authenticated;
grant select, insert, update, delete on table public.reports to service_role;
Enter fullscreen mode Exit fullscreen mode

A few things to notice:

  • anon gets nothing on reports. Not "nothing because of RLS", but nothing because there is no grant. Two independent layers both say no.
  • authenticated doesn't get update or delete. If your feature doesn't need it, don't grant it. You can add it with a policy later.
  • Grants, RLS and policies live in the same file. Supabase's changelog recommends exactly this: treat the three steps "as a unit" and bundle the GRANT statements alongside ENABLE ROW LEVEL SECURITY and the policies in the same migration.

If an insert fails with a permission error on a sequence (for example if you used serial instead of an identity column), grant usage, select on that specific sequence to the role that inserts. Don't reach for a schema-wide grant.

Trap 3: October 30 doesn't fix tables that are already open

"Existing tables keep their grants" means nothing breaks. It also means any table that is in the red row today stays in the red row. Whether it's last month's hackathon project or an app with real users, audit it before you add anything new.

Check 1: ask Postgres which tables anon can read with RLS off

select c.relname as table_name
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
  and has_table_privilege('anon', c.oid, 'SELECT');
Enter fullscreen mode Exit fullscreen mode

Any row this returns is a table readable with your public key. Views don't have RLS, so check relkind = 'v' separately.

A subtle point from the x3blank post: if you audit with information_schema.role_table_grants from a restricted role, you can get zero rows and wrongly conclude there are no grants. That view only shows privileges where the current role is involved as grantor or grantee. has_table_privilege() doesn't have that problem.

The dashboard can help too: the changelog points to the Data API exposure badge in the Table Editor and to the Security Advisor as the places that list the tables worth reviewing.

If a table should not be reachable through the Data API at all, the changelog gives the per-table revoke:

revoke all on table public.your_table from anon, authenticated, service_role;
Enter fullscreen mode Exit fullscreen mode

Check 2: use the anon key like an attacker would

This is the check I trust most, because it tests the real path:

curl -s -o /dev/null -w '%{http_code}\n' \
  -H "apikey: $ANON_KEY" \
  -H "Authorization: Bearer $ANON_KEY" \
  "https://<project-ref>.supabase.co/rest/v1/reports?select=*&limit=1"
Enter fullscreen mode Exit fullscreen mode

For a private table, a 200 that returns rows is a red flag. An error or an empty result is what you want. Run it for every table before you call the audit done, and again after every migration that adds a table.

Check 3: opt in early so there are no surprises

You don't have to wait for October 30. The changelog gives the SQL to adopt the new behavior on an existing project today:

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;
Enter fullscreen mode Exit fullscreen mode

These change the defaults for future tables and sequences created by the postgres role in public. Existing tables keep their current grants. Keep the for role postgres part. You can confirm what defaults remain with:

select pg_get_userbyid(defaclrole) as grantor,
       defaclnamespace::regnamespace as schema,
       defaclobjtype,
       defaclacl
from pg_default_acl;
Enter fullscreen mode Exit fullscreen mode

Doing this this week means you experience the new behavior on your own schedule, on a day you're watching for it, instead of discovering it the first time someone adds a table after October 30.

If an AI assistant writes your migrations

Supabase's changelog explicitly suggests updating your AI tool's system prompt, or adopting the Supabase agent skill, so that grants are part of table creation. Whatever tool you use, I'd add three lines to its instructions:

- Every CREATE TABLE in public must be followed, in the same migration, by:
  revoke defaults, enable RLS, explicit policies, and minimal GRANTs per role.
- Never grant anything to anon unless the data is meant to be public.
- Never use GRANT ALL to fix a 42501 error. Explain which role needs which privilege and why.
Enter fullscreen mode Exit fullscreen mode

Then review what it produces. The error-message hint is designed so agents can self-correct, but an agent that self-corrects by granting SELECT to anon on a private table has fixed the error and created a leak.

The same principle, outside Supabase

None of this is Supabase-specific thinking. It's least privilege, made explicit. Two examples from my own projects, with different stacks:

  • TwinFlow (a Go sidecar in front of PostgreSQL) says it plainly in its README: "It is a proxy, not a permission system: the Postgres role you give it is the access it has." The tool doesn't pretend to add security on top of a role that's too powerful. The role is the boundary, so you choose the role carefully. Its contributing guide also forbids logging SQL arguments, row payloads, tokens or connection strings.
  • Ezoato (a past-exam-papers platform with a PHP/MySQL backend) documents that its AI session endpoint only returns sessions belonging to the current JWT, responding with a 404 otherwise, and ships a security test script that covers this kind of ownership check (IDOR) alongside input validation.

Different databases, same rule: decide who can touch what, write it down in code, and test it from the outside.

The checklist to run before October 30

This week, on every existing project

  • [ ] List the projects that use the Data API (supabase-js, any client library, /rest/v1/ or /graphql/v1). Projects that only use a direct Postgres connection can skip the rest.
  • [ ] Run the has_table_privilege audit and fix any red-row tables (enable RLS with policies, or revoke per table).
  • [ ] Check the Security Advisor and the Data API exposure badge for flagged tables.
  • [ ] Decide whether to run the official opt-in SQL now, so the project behaves like a new one on your schedule.
  • [ ] Make sure the service_role key is only in server-side environment variables, never in a NEXT_PUBLIC_/VITE_/Flutter asset file.

In your repo

  • [ ] Put the two migration templates above in your repo (supabase/migrations/ or a sql/ folder).
  • [ ] Find every migration, seed script or provisioning step that creates a table in public and add explicit grants next to it.
  • [ ] Add the three instructions above to your AI assistant's system prompt or project rules.

Every time you add a table

  • [ ] Create → revoke → RLS → policies → minimal grants, in one file.
  • [ ] When you see 42501: ask which role needs which privilege, never GRANT ALL.
  • [ ] Review AI-generated SQL for grants to anon.
  • [ ] Run the anon-key curl against the new table.

Before October 30

  • [ ] Rebuild from migrations once (new project, branch or local reset) to prove the migrations are complete on their own.
  • [ ] Delete abandoned tables and the SQL files that create them, so they can't come back on the next rebuild.
  • [ ] Run the has_table_privilege query one last time.

The October 30 change makes new tables safe by default. Your job is to not undo that with a rushed GRANT ALL the first time something breaks.


Sources

All diagrams in this post are mine, made for this article.

Top comments (0)