# Data API grants

> Grant new tables to the Data API roles now that Supabase no longer does it for you.

Source: https://bettersupabase.com/docs/guides/data-api-grants

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 [#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`).

```ts title="better-supabase.config.ts"
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:

```bash
better-supabase sql add grants
supabase db schema declarative sync -f data_api_grants
```

A 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:

```ts title="better-supabase.config.ts"
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 [#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](/docs/cli/doctor#bs115), until the migration is applied.

```ts title="better-supabase.config.ts"
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](/docs/cli/doctor#bs100)). Grant `anon` only
what signed-out visitors need, and leave `truncate`, `references` and
`trigger` out ([BS111](/docs/cli/doctor#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 [#find-missing-grants]

`better-supabase doctor` reports [BS106](/docs/cli/doctor#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:

```sql title="supabase/schemas/audit_logs.sql"
revoke all on table public.audit_logs from public, anon, authenticated;
grant select, insert on table public.audit_logs to service_role;
```

```ts title="better-supabase.config.ts"
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 [#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:

```ts
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.