← All posts
·8 min read·GuardLayer

Supabase: tables not exposed to the Data API after the 2026 grant change

SupabaseData APIRLSGrantsPostgREST

TL;DR: Since the 2026 Data-API change (Supabase changelog #45329), a table in an exposed schema like public reaches Supabase's auto-generated REST and GraphQL API only when you explicitly GRANT it to the anon and/or authenticated role. New projects have behaved this way since 2026-05-30; existing projects flip on 2026-10-30. If your REST call started returning a 404 / "table not found" / empty results after you created a table with raw SQL or an ORM migration, the calling role was simply never granted access. The safe fix is to grant to authenticated and enable RLS — not to grant to anon with RLS off, which re-opens the table to the whole internet.

What changed in the 2026 Supabase Data-API grant update

Before this change, Supabase handed the anon and authenticated roles blanket table access with a single GRANT ... ON ALL TABLES IN SCHEMA public. Every table you created in public was therefore reachable through the API the moment it existed — and if RLS was off, it was readable and writable by anyone holding the anon key.

The 2026 change (changelog #45329, "tables not exposed to Data and GraphQL API automatically") removes that automatic exposure for newly created tables. A table in an exposed schema now reaches the auto-generated Data API (PostgREST/REST) and the GraphQL API (pg_graphql) only via an explicit GRANT to anon and/or authenticated. Two dates matter, and only two:

  • New projects: default to the no-auto-grant behavior since 2026-05-30.
  • Existing projects: flip to it on 2026-10-30.

One thing this change does not do: it does not touch RLS. It only removes automatic API exposure (the grant). A table can now be invisible to the API and still have RLS off — those are two separate mechanisms, and conflating them is where the footgun lives.

Why is my Supabase table "not exposed to the API"?

The symptom is consistent. You create a table with the SQL editor, psql, or an ORM migration (Prisma, Drizzle), then your REST or GraphQL call returns nothing useful — a 404 / "table not found in the schema cache" / empty results — even though the table plainly exists in the database.

The cause is the grant, not your query and not your policies. The role your client calls as (anon for the public key, authenticated for a user JWT) was never granted table privileges, so PostgREST can't expose the table. Depending on the exact state, a direct query to a table the calling role has zero privileges on throws Postgres 42501 permission denied for table — which PostgREST forwards straight through — while PostgREST may instead hide the table from its schema cache entirely, so you get a "not found" 404 rather than a permission error. Either way the root cause is identical: no grant. Our companion post on permission denied for table (42501) walks the error-message path in detail.

Grants vs RLS: two different locks

Postgres checks access in a fixed order, and the grant check runs long before RLS:

  1. Database CONNECT
  2. Schema USAGE
  3. Table privilege (SELECT / INSERT / UPDATE / DELETE)
  4. Column and sequence privileges
  5. Row-Level Security policies

RLS is dead last. With no grant at layer 3, Postgres never reaches layer 5 — PostgREST can't expose the table and no policy ever executes. That is why adding more RLS policies does nothing for a "not exposed" table. Keep the mental model straight:

Grants decide WHETHER a role can touch a table. RLS decides WHICH rows it sees.

You almost always want both, configured together in the same migration so they never drift. The complete guide to Supabase RLS covers the row-filtering half end to end.

The correct fix for logged-in-user data

For anything user-scoped, grant to authenticated — never anon — then enable RLS and scope the rows:

-- 1. schema access (checked before any table privilege)
grant usage on schema public to authenticated;

-- 2. table privileges — only the verbs you need
grant select, insert, update, delete on public.notes to authenticated;

-- 3. turn RLS on so the table fails closed the moment it's reachable
alter table public.notes enable row level security;

-- 4. scope every row to its owner
create policy "own rows" on public.notes
  for select using ((select auth.uid()) = user_id);

-- 5. serial/bigserial PKs also need the sequence
--    (GENERATED ... AS IDENTITY columns are exempt)
grant usage, select on all sequences in schema public to authenticated;

(select auth.uid()) instead of a bare auth.uid() lets Postgres evaluate the subquery once per statement rather than once per row — a free performance win with no change to security behavior.

The dangerous fix people reach for: granting to anon

Here is the trap. The REST API returns 404, you're in a hurry, and you "open it up" to the client with an anon grant and no RLS:

-- 20261030_open_waitlist_to_api.sql
-- The REST API started returning 404 for this table after the 2026
-- Data-API change, so we "opened it up" to the client. This hands every
-- anonymous visitor direct read/write access with no policy in front of it.
create table public.waitlist (
  id uuid primary key default gen_random_uuid(),
  email text not null,
  referred_by text,
  created_at timestamptz default now()
);

grant select, insert on public.waitlist to anon;
guardlayer scan · supabase/migrations/20261030_open_waitlist_to_api.sqlLive engine output
Passed with warnings
84/100 · B
  • Warningsupabase/migrations/20261030_open_waitlist_to_api.sql:5

    Table created without enabling RLS

    Add ALTER TABLE <table> ENABLE ROW LEVEL SECURITY; plus access policies right after the CREATE TABLE.
  • Warningsupabase/migrations/20261030_open_waitlist_to_api.sql:12

    Table granted to the anon role without RLS

    Enable RLS on the table (ALTER TABLE <t> ENABLE ROW LEVEL SECURITY;) and add scoped policies, or revoke the grant from anon. Only keep an anon grant for genuinely public data that is still RLS-protected.

Before 2026, a stray anon grant was almost redundant — the blanket grant already exposed the table. Now that explicit grants are the only path to the API, grant ... to anon with RLS off is a deliberate public re-exposure. The anon key ships in every browser bundle, so this hands every anonymous internet visitor direct read/write on the table with no policy in front of it. That is exactly the CVE-2025-48757 class of leak — the May 2025 disclosure where 303 endpoints across 170 production apps leaked data through the public anon key.

Note that a real scan of this migration surfaces two warnings, not one: supabase/grant-to-anon (the anon grant with no RLS) and supabase/rls-missing-on-table (a public table created without RLS in the same file). The migration trips both because it does both wrong things at once; the anon grant is the one highlighted above.

When granting to anon is actually fine

An anon grant is legitimate for genuinely public data — a blog_posts-style table with nothing private — but only if RLS stays enabled with a read-only policy so anonymous writes are still blocked:

alter table public.blog_posts enable row level security;
grant select on public.blog_posts to anon;
create policy "public read" on public.blog_posts
  for select to anon using (true);

"Public read" and "RLS off" are not the same thing. Read-only with a using (true) policy blocks anonymous inserts and updates; RLS off blocks nothing. If you do reach for using (true), know its failure modes first — it's the right tool only for data that is truly meant to be world-readable.

FAQ

Why did my table stop showing up in the REST/GraphQL API in 2026? Because Supabase stopped auto-granting new tables to anon/authenticated. The table exists, but the API role has no privilege on it, so PostgREST won't expose it. Grant the role the privileges it needs and it reappears.

Does the 2026 change turn RLS on for me? No. It only removes automatic API exposure (the grant). RLS is a separate mechanism you still enable yourself with alter table ... enable row level security. A table can be invisible to the API and still have RLS off.

Should I grant to anon or authenticated? authenticated for anything tied to a logged-in user; anon only for data you intend to be world-readable — and even then keep RLS on with a read-only using (true) policy. Never give anon write access unless anonymous writes are the actual goal.

My INSERT still fails after I granted the table. Why? You're likely hitting the sequence behind a serial/bigserial primary key — a separate 42501 on the sequence. Run grant usage, select on all sequences in schema public to authenticated;. GENERATED ... AS IDENTITY columns don't need it.

How do I check what's actually granted right now? Query the catalog — it's the runtime source of truth: select grantee, privilege_type from information_schema.role_table_grants where table_schema = 'public';. Anything granting anon a write privilege on a table with RLS off is a hole.

Do existing projects need to do anything before 2026-10-30? Audit any table you rely on being reachable through the API and make sure it has explicit grants, so nothing silently 404s on the flip. Then confirm no table is granted to anon without RLS.

Where GuardLayer fits (and where it doesn't)

GuardLayer ships a rule, supabase/grant-to-anon (severity warning, CWE-732), that flags a public-schema table granted to the anon role when RLS is never enabled for it in the same migration file — the exact pattern in the sample above. Be clear about the limits, because it's a static, per-file scanner. It correlates the GRANT and ENABLE ROW LEVEL SECURITY only within a single .sql file, so RLS enabled in a different migration than the grant is a false positive, and a grant with RLS in the same file is suppressed. It deliberately does not flag the schema-wide GRANT ... ON ALL TABLES IN SCHEMA public TO anon, only per-table grants. And it cannot see live privilege state — whether a migration was actually applied, whether your project has flipped to the new default yet, or grants added via the dashboard or psql outside the repo. For that, the information_schema.role_table_grants query above is the source of truth. GuardLayer also doesn't block your merge on its own: it posts a check, and blocking requires you to make it a required status check via branch protection. What it does do is catch the anon-grant-without-RLS pattern in code review, before that "quick fix" for a 404 ships a public table to production.

Catch this before it ships — free

GuardLayer scans every push for this and 24 other Next.js + Supabase issues, with the exact fix inline.

No signup, no card — your code is scanned in memory and never stored.

Keep reading