security

Supabase's October 30 Data API Change: How to Fix New Tables Safely

From October 30, 2026, new Supabase tables are unreachable from supabase-js until you grant access. What breaks, the safe fix, and why RLS comes first.

FinishKit Team12 min read

On October 30, 2026, Supabase applies a new default to every existing project: new tables in the public schema are no longer exposed to the Data API automatically. From then on, a table you create can't be read or written from supabase-js, REST or GraphQL until you grant access to it. Tables you already have keep working.

So if your app worked yesterday and a feature built on a brand-new table now fails with permission denied for table, your code isn't broken. The table just hasn't been opened to your app yet. The fix is a few lines of SQL. The part worth slowing down for is doing it without opening that table to everyone on the internet.

What changes on October 30

Before this change, Supabase granted select, insert, update and delete on every new public table to three roles: anon (signed-out visitors), authenticated (signed-in users) and service_role (server code using your secret key). Every table was reachable through the Data API the moment it existed, including tables nobody had protected yet.

Supabase has been phasing those automatic grants out:

DateWhat happens
April 28, 2026New projects can opt out of automatic exposure when they're created
May 30, 2026Opting out becomes the default for new projects, rolled out over a few weeks
October 30, 2026The new behavior applies to all existing projects

After the change, a new table needs an explicit grant before the Data API can see it. Existing tables keep their current grants and stay reachable. Row level security works exactly as before.

It helps to keep the two layers apart. A grant decides whether a role can touch a table at all. A row level security (RLS) policy decides which rows that role can see or change. This change only touches the first layer.

Who this affects

Supabase says you need to act if any of these is true:

  • Your app reads or writes public tables through the Data API: supabase-js, any Supabase client library, or direct requests to /rest/v1/ or /graphql/v1.
  • Your migrations create tables in public without explicit GRANT statements.
  • An AI coding tool, CLI script or Management API call creates tables and expects them to be reachable right away.

If an AI tool builds your app, this likely includes you. Lovable's docs say that when it's connected to your Supabase project, it writes the SQL for schema changes, runs it as a migration and saves the file under supabase/migrations/. Bolt can connect to your own Supabase project too. And Supabase is explicit that the new behavior holds however a table gets created: SQL Editor, migrations, Management API, MCP, CLI or an AI coding tool.

Fresh projects count as well. New projects have had this behavior by default since the rollout that began May 30, and Supabase's own timeline says new-project workflows must include explicit grants. If you replay older migrations with no GRANT statements into a new project (a staging copy, for example), the tables they create won't be reachable.

You're not affected if your app only talks to Postgres over a direct connection (psql, an ORM, or a server using a connection string). Supabase lists self-hosted projects as not affected, and tables in the storage, auth, realtime and custom schemas keep their current defaults.

Supabase's schedule lists an email to owners and admins of active projects on September 23 and a final notice on October 23. Until October 30, the Security Advisor flags affected tables in each project and shows the SQL to fix them.

What you'll see when it breaks

A query against a new table that has no grants returns an error instead of data. Supabase documents the response 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;"
}

In supabase-js, that comes back as the error from your query, with message, code, details and hint. Supabase notes that clients often surface 42501 errors as 401 or 403 responses. If your app doesn't display errors, it may just look like a page that loads with no data.

The hint names the role and the privilege that's missing, plus the statement that adds it. Read it before you paste it. The section on the quick fix below explains why.

Two look-alikes need different fixes:

  • An empty result with no error. This usually means the grant is fine and row level security is filtering out every row, because no policy matches this user. Check the policy, not the grant. (An empty table or a filter that matches nothing looks the same.)
  • A 42501 on an insert or update after you've added grants. Supabase's troubleshooting guide lists a missing RLS policy for that operation as another cause of 42501. Add the policy.

Server code isn't exempt. The secret key (the service_role role) skips row level security, but a missing grant still returns a permission error, so an Edge Function that uses the secret key needs a grant on a new table too.

How to expose a new table, safely

First decide who should reach the table:

  • authenticated: signed-in users. Your policies decide which rows each one gets.
  • anon: signed-out visitors. Grant this only for data that's meant to be public, and usually only select.
  • service_role: server-side code using your secret key. It bypasses row level security, so the key never belongs in the browser.

Then turn on row level security, add policies, and grant only what each role needs. Supabase's advice is to treat these as one unit and keep them in the same migration. Run the SQL in the Supabase SQL Editor for a one-off fix, or add it as a migration so it lives with your code (if you use Lovable, it writes the migration and runs it after you approve). Here's the pattern for a table where each user owns their rows (swap in your table name and owner column):

-- 1. Turn on row level security
alter table public.your_table enable row level security;
 
-- 2. Add the policies you need
create policy "Users can read their own rows"
  on public.your_table
  for select
  to authenticated
  using ((select auth.uid()) = user_id);
 
create policy "Users can add their own rows"
  on public.your_table
  for insert
  to authenticated
  with check ((select auth.uid()) = user_id);
 
-- 3. Clear any old automatic grants, then grant only what each role needs
revoke all on table public.your_table from anon, authenticated;
grant select, insert on public.your_table to authenticated;
 
-- Only if server code with the secret key uses this table
grant select, insert, update, delete on public.your_table to service_role;

The revoke line matters for tables created before the change. They still carry the old automatic grants, including insert, update and delete for anon, and adding a policy doesn't take those away. On a brand-new table there's nothing to clear, so it changes nothing. On an existing table your app already updates or deletes rows in, the revoke removes those grants for authenticated too, so add the update and delete grants and policies shown below in the same migration.

When users should edit their own rows too, add an update policy and the matching grant. The using clause checks the row as it is now, and with check checks the row after the change:

create policy "Users can update their own rows"
  on public.your_table
  for update
  to authenticated
  using ((select auth.uid()) = user_id)
  with check ((select auth.uid()) = user_id);
 
grant update on public.your_table to authenticated;

Deletes work the same way: a for delete policy with a using clause, plus grant delete.

For a table signed-out visitors should read (a public product list, say), turn on row level security, clear the old grants, grant select to anon and give it a read policy. using (true) makes every row public, so keep it for data that really is:

alter table public.your_table enable row level security;
 
revoke all on table public.your_table from anon, authenticated;
grant select on public.your_table to anon, authenticated;
 
create policy "Anyone can read"
  on public.your_table
  for select
  to anon, authenticated
  using (true);

If the table has a serial or bigserial id, inserts also need access to its sequence, because the new defaults stop granting sequences too. Postgres names that sequence tablename_colname_seq:

grant usage, select on sequence public.your_table_id_seq to authenticated;
 
-- Only if server code with the secret key inserts into this table
grant usage, select on sequence public.your_table_id_seq to service_role;

Database functions you call with .rpc() may need the same treatment. Supabase's changelog examples cover tables and sequences, but its API security guide says new functions in public on existing projects get EXECUTE by default and includes functions in its revoke block, and the project setting is labelled for tables and functions. If .rpc() on a new function fails with a permission error, grant execute only to the roles that should call it. Row level security doesn't apply to functions, so be deliberate about who gets that grant:

grant execute on function public.your_function() to authenticated;

The empty parentheses only match a function that takes no arguments. If yours takes some, list their types inside them, for example public.your_function(uuid), or Postgres reports that the function does not exist.

Supabase's troubleshooting guide says you can also change table privileges in the Dashboard under Integrations > Data API. Keeping the SQL in a migration is still the better habit: the next fresh project or staging copy gets the same access without anyone redoing it by hand.

To check what a table has now, run these in the SQL Editor:

-- Which roles hold which privileges on this table
select grantee, privilege_type
from information_schema.role_table_grants
where table_name = 'your_table';
 
-- Which public tables have row level security on
select tablename, rowsecurity
from pg_tables
where schemaname = 'public'
order by tablename;

If an AI tool writes your migrations, ask it to put the grants next to the row level security and policies in every new table's migration. Supabase suggests updating your tool's instructions or using its agent skill, which includes the grants step.

Why the quick fix can leak your data

When a feature is broken late at night, two shortcuts look tempting: pasting the hint's GRANT ... TO anon as is, or granting every privilege on every table at once. Supabase's FAQ even shows a bulk grant like that, as a way to roll back an early opt-in.

Here's the risk. Supabase's docs are direct about it: a table the Data API can reach, without row level security, can be read and written in full by anyone holding your project's publishable key. That key is meant to ship in your frontend, so anyone can find it. Granting anon access to an unprotected table is the same as publishing it.

Unprotected tables are common in AI-built apps. The Supabase Table Editor turns on row level security when you create a table in the Dashboard, but a table created with SQL, the way migrations and AI tools create them, needs you to enable it yourself.

This isn't hypothetical. UpGuard Research looked at roughly 300,000 domains showing signs of Supabase use and found 16,326 databases exposing readable tables. Over half had indicators of personal data.

So read the October 30 change as a safety net. A table you forget to protect is now unreachable instead of public. Keep it that way: grant per table and per role, with row level security already on. For more policy patterns, see the Supabase RLS guide, the missing row level security fix, and what row level security means if the term is new.

Checklist

  1. Check Supabase's email and the Security Advisor for tables it flags.
  2. Confirm row level security is on for every table your app reads through supabase-js (the pg_tables query above).
  3. Make every new-table migration carry three things together: enable row level security, policies, and grants.
  4. Grant anon only select, and only on data meant to be public.
  5. Grant service_role only where server code needs it, and keep the secret key out of the browser.
  6. Add a sequence grant for any new serial or bigserial id.
  7. Test as a signed-out visitor and as a second user: private tables should return an error or nothing, never someone else's rows.
  8. If you start a fresh project, replay your migrations there and confirm the app still loads its data.

A second pair of eyes

Grants are now one more line to remember in every migration, and row level security is still what decides who sees what. If you'd like a second pair of eyes on the row level security side before you ship, FinishKit's full review reads your repo, Supabase migrations included, and looks for tables with missing or overly permissive policies. What it finds goes into your Finish Plan, ordered by what to fix first. To run it, install the FinishKit GitHub App and choose which repositories it can access. If your repo is public and you want a quick first look at overall readiness, the free readiness check takes a GitHub URL and runs a short set of checks, no sign-up needed.

Sources