Data API grants
Grant new tables to the Data API roles now that Supabase no longer does it for you.
Supabase used to grant every new table in public to anon, authenticated
and service_role, so RLS was the only gate. That default is going away: new
projects stopped getting it on May 30, 2026, and existing projects lose it for
new tables on October 30, 2026. A table without a grant fails every Data API
request with 42501 permission denied for table ..., before any policy runs.
Tables that already exist keep their grants. The tables you add after the cutover are the ones that break.
Declare what each role reaches
List the tables and privileges in expose. An array means privileges for
authenticated; an object sets them per role. service_role gets every
privilege unless the entry sets serviceRole. Functions are keyed by their
signature, and execute lists the roles that may call them (service_role
is added unless serviceRole: false).
export default defineConfig({
expose: {
customers: ["select", "insert", "update", "delete"],
organizations: ["select"],
"public.pricing_plans": { anon: ["select"], authenticated: ["select"] },
"public.audit_exports": { serviceRole: ["select", "insert"] },
"search_notes(text, integer)": { execute: ["authenticated"] },
},
sql: { modules: ["grants"] },
});Then write the grants into your declarative schema and diff a migration:
better-supabase sql add grants
supabase db schema declarative sync -f data_api_grantsA privilege can name columns, such as "update(title, body)" or
"select(id, name)", for select, insert and update. The module writes
it as a column grant (grant update (title, body) on table ...), so the role
can change only those columns:
expose: {
"public.posts": {
anon: ["select(id, title, published_at)"],
authenticated: ["select", "insert", "update(title, body)"],
},
},The CLI validates the config with the same rule, so sql sync accepts a
column privilege in better-supabase.config.ts or .json and rejects a
string that is neither a privilege nor a column list. Doctor (BS106) checks
the table-level privileges only, since column grants don't show in a table's
privileges.
Each entry is the complete privilege set of that table or function. The
grants module first revokes everything from public, anon,
authenticated and service_role, then grants what the entry lists, so a
privilege you remove from expose is revoked on the next sync, and
truncate, references and trigger never reach the API roles. Tables and
functions that expose doesn't list keep their grants. RLS still decides
which rows a role sees.
Grants from your policies
With fromPolicies, the module also derives grants for the tables that
expose doesn't list from the permissive policies in your declarative
schema files (your migrations only when the project has none, so a table or
policy an old migration created and a later one dropped doesn't come back),
following drop policy and drop table statements: a role named in a policy's to clause gets the policy's command
(for all gives all four), and service_role gets every privilege.
Restrictive policies grant nothing. A policy without to (or to public)
counts for authenticated only, so anon needs a policy that names it.
Tables in expose keep their listed privileges.
A table the schema files create and enable row level security on, that no
expose entry and no permissive policy reaches, is service-only (tables
without RLS keep their grants): the module revokes everything from public,
anon and authenticated and grants service_role every privilege, as if
expose listed it with authenticated: []. To open such a table, add a
policy for the roles that need it, or list it in expose. Doctor reports a
table like that which still holds grants on the live database as
BS115, until the migration is applied.
export default defineConfig({
schemas: ["public"],
sql: { modules: { grants: { options: { fromPolicies: true } } } },
});Only tables in the generated schemas are derived. Run sql sync after
changing a policy so the grants follow.
A grant on a table in an exposed schema is a door to the internet: anyone with
the publishable key can call the Data API as anon, and any signed-in user as
authenticated. Pair every grant with alter table ... enable row level security and policies for each role and command you grant, in the same file.
A table with grants and RLS off is readable and writable by everyone; doctor
reports it as an error (BS100). Grant anon only
what signed-out visitors need, and leave truncate, references and
trigger out (BS111).
Tables with a serial column also need usage on the sequence for inserts
(identity columns don't). Functions called with db.$rpc() need execute.
Find missing grants
better-supabase doctor reports BS106 for every
table the Data API roles can't reach. Tables in expose need the privileges
listed there. Other tables in the generated schemas need at least select
for authenticated, except the tables with RLS on that fromPolicies makes
service-only.
Some tables are for the server only: an audit log or a job queue that only
service_role reads and writes. Revoke the Data API roles and set
serviceRole: true on the table, which keeps its generated types for the
admin client:
revoke all on table public.audit_logs from public, anon, authenticated;
grant select, insert on table public.audit_logs to service_role;export default defineConfig({
tables: { audit_logs: { serviceRole: true } },
});Doctor then reports BS106 if anon or authenticated can still reach the
table. tables: { internal_x: { exclude: true } } also silences BS106, and
also leaves the table out of the generated models.
If supabase/config.toml sets [api] auto_expose_new_tables = false, the
finding says so: that's the local equivalent of the new hosted default, and
it's the setting to use locally so you hit missing grants before production
does.
At runtime
A missing grant comes back as a forbidden DbError (status 403). Its
hint keeps PostgREST's GRANT ... suggestion and adds a pointer to
expose, so the log line tells you what to fix:
const { error } = await db.pricingPlans.findMany();
// error.kind === 'forbidden'
// error.hint === 'Grant the required privileges ... Supabase no longer grants new tables ... add pricing_plans to `expose` ...'RLS violations (new row violates row-level security policy) are also
42501 and also forbidden, but have no grant hint, because granting would
not help.
Last updated on