doctor
Security, performance and drift checks for your database, config.toml and env files.
pnpm better-supabase doctordoctor introspects the database the same way gen does,
then reads supabase/config.toml, your .env* files and .gitignore. Every
finding has a code, a severity and a link to its section on this page. When a
finding is about a table, function or policy, doctor points at the file that
creates it: declarative schemas in supabase/schemas first, then the newest
migration.
It also runs Supabase's own Security and Performance
Advisors (BS100
and BS200), so the dashboard's findings show up locally and in CI. It exits
with 1 when there are errors, or with any warning under --strict.
| Option | Description |
|---|---|
--format <f> | text (default), sarif or github; also doctor.format. The global --json prints the JSON report |
--out <file> | Write the report to a file; also doctor.output, which applies with doctor.format |
--only <codes> | Run only these checks, e.g. --only BS100,BS304; also doctor.only |
--ignore <codes> | Skip checks. Adds to doctor.ignore in the config |
--strict | Fail on warnings. Same as doctor.strict |
--snapshot <file> | Check a saved snapshot instead of the database |
--metadata <path|-> | Check a GeneratorMetadata document (- for stdin). Checks that need policies, grants, indexes, triggers or other data it lacks are skipped with an info finding; it exits 65 for a document it rejects (gen) |
--db-url-stdin | Read the connection string of the database to check from stdin |
--project-ref <ref> | Hosted project to read through the Management API (needs SUPABASE_ACCESS_TOKEN) |
--stats | Report slow frequent statements from pg_stat_statements (BS209) |
--explain <tables> | Plan these tables under RLS (BS212) |
--as <uuid> | With --explain: plan as this authenticated user. Also measures the claims the custom access token hook returns for them (BS405) |
--claims <json> | With --explain: plan with these JWT claims |
--fix-grants | Print the grant and revoke SQL that BS404 asks for, as one block for a schema file or migration |
Doctor never prints env values, only variable names and line numbers.
In CI
--format github writes workflow annotations, so findings show up on the pull
request diff:
- run: pnpm better-supabase doctor --format github --strictThe github and sarif formats write file paths relative to the repository
root (the directory that holds .git, else the Supabase project root), so
annotations land on the right files when the CLI runs from a package. The text
format keeps paths relative to the project directory.
For GitHub code scanning, upload SARIF:
- run: pnpm better-supabase doctor --format sarif --out doctor.sarif
- uses: github/codeql-action/upload-sarif@v3
if: always()
with:
sarif_file: doctor.sarifTo keep the workflow step to better-supabase doctor, put the CI settings in
the config's ci environment, which the CLI
applies when $CI is set:
export default defineConfig({
$ci: { doctor: { format: "sarif", output: "doctor.sarif", strict: true } },
});doctor --json prints one report that follows
doctor-report-v1.json.
Ignoring checks
export default defineConfig({
doctor: { ignore: ["BS204"], strict: true },
});doctor.sources lists the app files that checks such as BS210 scan, as globs
relative to the project root. The default is ['src/**/*.{ts,tsx}'].
doctor.claimsLimit is a BS405 limit in bytes: the budget of the
authorization provider's hook when
it sets tokenHook.budget, otherwise the limit for the whole token (default
2048).
doctor.policyHelperLimit (default 5) is how many policies a security definer
helper may appear in before BS206 reports it.
The full doctor block, generated from the DoctorConfig type:
Prop
Type
Supabase advisors
BS100
Security Advisor findings. Each finding keeps the advisor's own severity
(error, warning or info), title and remediation link. The advisor covers RLS
disabled in exposed schemas, RLS without policies, security definer functions
anon or authenticated can call, mutable search_path, auth.users exposed
through views, user_metadata in policies and more. The message ends with the
lint name, for example [rls_disabled_in_public].
Where the findings come from depends on what doctor reads:
- Hosted projects (
--project-reforsource.projectRef): the Management API'sGET /v1/projects/{ref}/advisors/security, the same data the dashboard shows. - Local stack,
$DATABASE_URL,$SUPABASE_DB_URLor--db-url-stdin: splinter, the SQL behind the advisors. Doctor downloadssplinter.sqlat a pinned commit, checks its SHA-256, caches it innode_modules/.cache/better-supabaseand runs it in a read-only transaction that is rolled back. splinter has no license, so it isn't bundled. - Saved snapshots: there is no database to lint, so doctor reports an info finding saying the advisors were skipped.
If the advisors can't run (offline, no access token), doctor reports a warning
instead of failing silently. For --project-ref, use a scoped personal access
token limited to the project and the advisor and read-only query permissions
(see Hosted projects),
not a classic token with access to your whole account.
BS200
Performance Advisor findings. Same sources as BS100: unindexed foreign
keys, auth.uid() re-evaluated per row in policies, multiple permissive
policies, unused and duplicate indexes, tables without a primary key, table
bloat and more. Doctor's own BS207 and BS216 repeat
splinter's multiple_permissive_policies and unindexed_foreign_keys; when
BS200 runs and the advisor loads, they skip the tables splinter reports, so
each problem shows up once.
Security
BS103
Error: policy allows anonymous writes. A permissive insert, update,
delete or all policy for anon or public uses true. Anyone can change
rows. Restrict the policy to authenticated and check ownership.
BS106
Error: table not granted to the Data API. Supabase no longer grants new
tables to anon and authenticated (new projects from May 30, 2026, existing
projects from October 30, 2026). Without a grant, every request fails with
42501 before RLS runs. Tables listed in expose
need exactly the privileges listed; other tables in the generated schemas need
select for authenticated, except tables with RLS on and no permissive
policy when
sql.modules.grants.options.fromPolicies is on: those are service-only, so
BS106 expects no grants for them (BS115). Add the table to expose
and run better-supabase sql add grants. When config.toml sets
[api] auto_expose_new_tables = false, the finding says so.
A table that only the server reads, such as an audit log written with the
secret key, is kept off the Data API on purpose. Mark it
tables: { audit_logs: { serviceRole: true } }: BS106 then checks the
opposite, that anon and authenticated (directly or through public) have
no grant on it, and reports any they have. Its models are still generated, so
an admin client keeps its types. exclude also silences BS106, but leaves
the table out of the generated models.
BS107
Warning: tenant table without a policy for every command. A table with
the tenant column (or whose policies call a membership or tenant helper) has
permissive policies for authenticated covering some of select, insert,
update and delete, but not all. RLS denies the missing commands without
an error, so an update or delete matches 0 rows and the UI looks like it
worked. Add the missing policies, or a restrictive using (false) policy to
make a read-only table explicit, and cover the table with
expectTenantIsolation. The tenant table
itself (the one the tenant column references) is skipped.
BS108
Error: policy reads auth.mfa_factors directly. authenticated can't
select from auth.mfa_factors, so the policy fails every request with
42501. Use (select better_supabase.mfa_satisfied()) from
better-supabase sql add mfa, a security definer function. See
MFA and SSO.
BS109
Warning: auth.role() in a policy or function. Supabase deprecated
auth.role(). Doctor reads every policy, the functions policies call and the
functions in the exposed schemas. Scope the policy with to authenticated
instead, or read the claim with (select auth.jwt() ->> 'role').
BS110
Warning: update policy without a select policy or with check. An update
finds its rows through the select policies first, so a role with an update
policy but no select policy updates nothing and gets no error. Add a select
policy for the same role. An update policy without with check is an info
finding: Postgres then checks the new row against using, which leaves
unstated whether a row may move to another owner or tenant. Write both:
create policy notes_update on public.notes for update to authenticated
using (organization_id in (select better_supabase.member_organization_ids()))
with check (organization_id in (select better_supabase.member_organization_ids()));BS111
Warning: API roles hold privileges they don't use. anon and
authenticated never need truncate, references or trigger on a table,
and truncate skips RLS. The finding lists the revoke. A grant to anon
on a table with RLS where no policy applies to anon is an info finding: it
lets no request through, but lists the table in the Data API schema that
anyone with the publishable key can read.
BS112
Warning: exposed security definer function that doesn't check the
caller. A security definer function runs as its owner, past RLS. In an
exposed schema the Data API serves it at /rpc/<name>, so when anon or
authenticated may execute it and its body never reads auth.uid(),
auth.jwt() or the request claims, any caller gets the owner's access. Check
the caller in the body, revoke execute from the API roles, or move the
function to a schema the API doesn't expose. Trigger functions are skipped;
/rpc can't call them. Needs a snapshot taken with this version, which
records who may execute each function.
BS113
Info: storage upload policy without the upsert policies. An upload with
upsert: true (the x-upsert header) needs select and update policies
on storage.objects besides insert. Without them, replacing a file that
exists fails with 403. Doctor reads the policies in supabase/schemas and the
migrations; add the missing ones with the same bucket and path check.
BS114
Error: policy helper reads user_metadata. Users can change their own
raw_user_meta_data with auth.updateUser(), so a function that reads
user_metadata to decide access lets them grant themselves rights. Read
app_metadata, which only the service role can write. Splinter's
rls_references_user_metadata (BS100) covers the policy text; this check
covers the functions policies call, and the functions those call.
BS115
Warning: table without a policy still granted to the Data API. A table
with RLS on and no permissive policy for anon or authenticated answers
every request with no rows or a denied write, yet a grant lists it in the API
schema. With sql.modules.grants.options.fromPolicies, better-supabase sql sync
revokes anon, authenticated and public on every table in schemas with
RLS on that no expose entry and no permissive policy reaches, and keeps the privileges
of service_role. Until the migration is applied, doctor reports the grants
that are still on the database. Tables in expose, tables.<name>.exclude
and tables.<name>.serviceRole are skipped.
BS116
Warning: security definer function open to the API roles but not
exposed. Postgres grants execute on a new function to public, so a
security definer function in an exposed schema is callable at /rpc by
anon and authenticated unless it is revoked. When no
expose entry lists the function and no
policy calls it, nothing in the project says the API roles should reach it.
Doctor never revokes anything. Run
revoke all on function schema.name(args) from public, anon, authenticated;,
then grant execute to the roles that call it (or list the function in
expose). BS112 reports the same functions when they also skip a
caller check. Needs a snapshot taken with this version, which records who may
execute each function.
Performance
BS204
Warning: tenant column without an index. With the tenant plugin, every query filters on the tenant column. Add an index that starts with it.
BS205
Warning: policy calls a slow function once per row. A policy passes a
column of the row, such as is_member(organization_id), to a function
Postgres can't inline: plpgsql or another non-SQL language, volatile,
security definer, or with any set option (set search_path = ''
included). The call runs once for every row the query reads. Doctor finds the
functions a policy calls in pg_depend, so helpers in private schemas count
too. Have the helper return the allowed values once and compare, which works
for security definer helpers as well:
create policy notes_member on public.notes for select to authenticated
using (organization_id in (select private.user_organization_ids()));BS206
Warning: security definer policy helper that cannot be inlined. A
security definer function appears in more than doctor.policyHelperLimit
policies (default 5). Postgres never inlines a security definer function, even
one written in language sql stable, so a call that takes a column of the row
runs once per row. Call it as (select helper(...)) when its arguments don't
come from the row, so Postgres evaluates it once per statement as an
InitPlan, or have it return the allowed ids as a set and compare with
organization_id in (select helper()).
BS207
Warning: several permissive policies for one command and role. Postgres
evaluates every permissive policy that applies to a command and role, and ORs
the results, so each extra policy adds its cost to every row. The message
lists each command and role with its policies; a policy for public counts
for every role. Merge each group into one policy with or. When
BS200 runs, splinter's multiple_permissive_policies reports the
tables it covers and this check skips them; on a saved snapshot, or when the
advisor can't run, this check reports every table.
BS208
Warning: queries spill to temporary files. Reads temp_files and
temp_bytes from pg_stat_database since the statistics were last reset,
with the current work_mem. When pg_stat_statements is installed, the
message lists the five statements that wrote the most temporary blocks. Add
indexes so large sorts go away, or raise work_mem for the role that runs
them. Needs a database; saved snapshots skip it.
BS209
Warning: slow frequent statements. With --stats, doctor reads
pg_stat_statements and reports statements with a mean time above 50 ms and
more than 1000 calls, slowest total first. When a statement names a table in
the exposed schemas, the finding points at the file that creates the table.
Without the extension, doctor reports an info finding with the
create extension command.
BS210
Warning: aggregates used while PostgREST disables them. PostgREST
answers aggregate(), _sum, _avg, _min and _max includes and list
facetCounts with PGRST123 unless pgrst.db_aggregates_enabled is on for the authenticator
role. Doctor reads the role's settings from the database and scans the files
in doctor.sources (default src/**/*.{ts,tsx}) for those calls. Turn
aggregates on in a migration:
alter role authenticator set pgrst.db_aggregates_enabled = 'true';
notify pgrst, 'reload config';Saved snapshots from before this check carry no role settings, so they skip it.
BS211
Info: statement timeouts for the Data API roles. PostgREST switches to
anon or authenticated for each request, so their statement_timeout
limits Data API queries; doctor reports the values set on anon,
authenticated and authenticator. A function's own
set statement_timeout = ... only applies to an RPC when PostgREST hoists
it into the transaction. When pgrst.db_hoisted_tx_settings is set without
statement_timeout, doctor warns for each exposed function that sets one.
It also reports each role's idle_in_transaction_session_timeout, which ends
a session that leaves a transaction open. A role setting applies to the role
a connection logs in as, not to set role, so for better-supabase/postgres
set it on the login role or with the pool's idleInTransactionTimeout.
BS212
Info: RLS plan for a table. --explain customers,notes runs, for each
table, in a transaction that is rolled back:
set local track_functions = 'all'; -- skipped if the role may not set it
select set_config('request.jwt.claims', '<claims>', true);
set local role authenticated; -- the claims' role, default anon
explain (analyze, buffers, format json) select * from public.customers limit 1000;That is the query findMany() sends, capped at Supabase's default max_rows.
--as <uuid> plans as { "sub": uuid, "role": "authenticated" };
--claims '{"role":"authenticated","tenant_id":"..."}' sets any claims your
policies read. Qualify tables outside the exposed schemas, such as
better_supabase.memberships.
The finding lists node types, timings and loops, never row data, with the
number of InitPlans and each function's share of the execution time from
pg_stat_xact_user_functions. A SubPlan that runs more than once means a
policy is evaluated per row, and the finding becomes a warning: wrap the
policy's function calls in (select ...) so they run once as an InitPlan.
--explain needs a direct connection (local stack, $DATABASE_URL, $SUPABASE_DB_URL or --db-url-stdin); the
Management API's read-only endpoint can't switch roles.
info BS212 RLS plan for a table
public.customers as authenticated 0000…00ff: 0.15 ms, 1 InitPlan.
Plan: Limit 0.14 ms ×1 > InitPlan 1: Result 0.13 ms ×1 > Seq Scan on
customers 0.14 ms ×1. Function time: better_supabase.current_tenant_id
0.13 ms over 1 call (83%).BS213
Warning: API roles can write the columns that grant access. RLS helpers
decide who sees a row from other rows: better_supabase.has_organization_role reads
memberships.user_id and memberships.role, and a contact-scoped helper reads
contacts.user_id and contacts.customer_id. Doctor collects the columns
that the helpers your policies call read, including the helpers those call,
plus the decidingColumns of the authorization provider. It warns when anon or authenticated may insert or update one of
those columns and a policy lets them write the row: a member who can update
their own role, or a portal contact who can change customer_id, grants
themselves access.
A table-level grant counts even after a column-level revoke, because
Postgres checks the table grant first. A grant to public counts for both
roles, and the SQL then revokes it from public too. The finding lists the SQL that keeps
the other columns writable:
revoke insert, update on public.contacts from authenticated;
grant insert (id, name, email), update (id, name, email) on public.contacts to authenticated;Helper bodies come from the database, or from your SQL files when the snapshot doesn't have them. Column grants need a snapshot taken with this version; older snapshots only show table grants.
BS214
Error: permission the provider's SQL functions don't fully answer in a
Storage or Realtime policy. An access policy on a bucket or topic reaches
the authorization provider's
functions through its sql templates, or through the access contract under
the provider access model. Those functions decide by role and scope, so a
permission with other conditions (such as ownerId = principal.id) would grant
every object or topic in the scope. Doctor reports every key whose
authorization.permissions entry isn't sqlComplete: true: a key marked
false, a key without the flag, a key the provider doesn't list, and every
key when the provider lists no permissions. better-supabase gen refuses to
write those bucket policies.
Doctor checks the buckets in the config and the bs_ policies on
storage.objects and realtime.messages in your SQL files, matching the
provider's idsWith and isPlatform templates. It also reports a scope the
provider doesn't declare, and a key checked at a scope its entry's scopes
doesn't list.
It warns when a bucket's sql templates differ from the provider's idsWith
and isPlatform, because a copy goes stale when the provider changes. Set
sql: "provider" so better-supabase gen writes the provider's templates.
BS215
Warning: policy calls a helper per row that could run once. A call such
as current_tenant_id() takes no column of the row, so its result is the same
for every row. Written bare, Postgres may run it for each row; wrapped as
(select current_tenant_id()), it runs once per statement as an InitPlan.
Splinter's auth_rls_initplan (BS200) covers auth.uid() and auth.jwt();
this check covers your own helpers. Immutable functions are skipped.
BS216
Info: foreign key without an index. Deleting or updating a referenced row
scans the referencing table for each row, and joins on the key can't use an
index. Add an index whose leading columns are the key's columns, in any order.
When BS200 runs, splinter's unindexed_foreign_keys reports the
tables it covers and this check skips them; on a saved snapshot, or when the
advisor can't run, this check reports every table.
BS217
Warning: tenant foreign key that can cross tenants. With the
tenant plugin, a tenant table that references another tenant
table by id alone can point at a parent in another organization: RLS checks
each row, not the pair. Reference the tenant column as well, backed by a
unique key on the parent:
alter table public.customers add unique (id, organization_id);
alter table public.notes add foreign key (customer_id, organization_id)
references public.customers (id, organization_id) on delete cascade;BS218
Info: soft-delete table without a partial index. A table with a nullable
deleted_at or archived_at column is mostly read for its live rows. A
partial index with where deleted_at is null stays small and matches those
queries:
create index customers_active_idx on public.customers (organization_id, created_at desc)
where archived_at is null;Snapshots from before index predicates were recorded accept any partial index.
BS219
Warning: containment filter on a column without a GIN index. @>, <@,
?, ?|, ?& and && on jsonb and array columns, and the
.contains(), .containedBy() and .overlaps() filters, can only use a GIN
index; without one, every query reads the table. Doctor looks in the
policies, the functions in the exposed schemas and the files in
doctor.sources. Use jsonb_path_ops when you only filter with @>, which
makes the index smaller; leave it out for ?:
create index notes_meta_idx on public.notes using gin (meta jsonb_path_ops);BS220
Info: column type to avoid. timestamp drops the time zone, varchar(n)
and char(n) only add a length limit that a check constraint on text
states as well, money rounds by locale, and json is parsed again on every
read. Use timestamptz, text, numeric and jsonb.
BS221
Warning: direct database connection in a serverless app. Each serverless
or edge invocation opens its own connection, so through the direct host
(db.<ref>.supabase.co, which is also IPv6 only) or the session pooler on
port 5432 they run out of max_connections. Use the transaction pooler on
port 6543 (postgres://postgres.<ref>:<password>@<region>.pooler.supabase.com:6543/postgres).
Doctor checks the env files when the sources export route handlers, a
runtime, a fetch handler or Deno.serve, or an env file sets VERCEL
variables. The message names the variable, never its value.
BS222
Warning: column that can exceed a JavaScript number. int8 and numeric
decode as number by default, which matches supabase gen types, but a
number loses precision past 2^53 (about 15 to 17 significant digits). Doctor
reports int8 identity and sequence columns, which keep growing toward that
limit, and numeric columns, which usually hold exact amounts. Set
codecs.int8 to "bigint" or "string", or codecs.numeric to "string",
in better-supabase.config.ts (codecs), or add BS222
to doctor.ignore when the values stay small.
Drift
BS301
Warning: soft delete hidden by a select policy. After an update, PostgREST
reads the row back through the select policy. If that policy hides rows where
the soft-delete column is set, softDelete() fails with an RLS error. Filter
deleted rows in queries instead; the soft-delete plugin already does.
BS302
Warning: bucket differs from the config. A bucket in buckets is missing
from the database, or its public, file size limit or MIME types differ.
When the Storage version has the columns, versioning and lifecycle are
compared too (generated lifecycle rule ids are ignored). Update the config,
or run bucket.apply(client) to set the bucket through the Storage API.
BS303
Error: generated code is out of date. The generated module doesn't match
the database, so types and metadata are wrong. Run better-supabase gen. This
is the same check as gen --check.
BS304
Warning: SQL module files are out of date. A module in sql.modules differs from
the version this release ships. Doctor renders the files with the same layout
as sql sync (grants from policies, session policies, audited tables and the
vector schema), so the two agree on what is out of date. Run better-supabase sql sync, then
supabase db schema declarative sync (supabase db diff on migra) to create
a migration.
For the read-sets module, doctor imports the modules in readSets and
compares the functions they compile to. If a module can't be imported,
doctor skips that file instead of reporting it.
BS305
Warning: live query table without change broadcasts. A table in
realtime.tables has no bs_realtime trigger, so
live queries never hear about its changes. Run
better-supabase sql add realtime-tables, then
supabase db schema declarative sync.
BS306
Warning: Realtime delete events without keys. A table in the
supabase_realtime publication has replica identity nothing, or default
without a primary key, so postgres_changes delete events carry no keys. Add a
primary key, or run alter table ... replica identity full.
BS307
Error: custom SQL module without its contract. A module in sql.modules uses
mode: 'custom', so the app writes the functions other modules and the
TypeScript APIs call. With a database connection, doctor compares each
function's argument and return types with the contract; without one, it looks
for a create function in supabase/schemas and the migrations. Write the
function, or switch the module to adopt or managed. better-supabase sql print <module> lists the signatures.
BS308
Warning: tenant claim the hook does not write. With the tenant module and
sql.modules.access.activeTenant: 'claim', current_tenant_id() and the
tenant() plugin read the active tenant from the claims.tenant claim, at the
top level or in app_metadata. With --as <user id>, doctor calls the custom
access token hook for that user and warns when neither is in the claims it
returns. Write the claim in the hook, or, for apps that pick the tenant from
the URL or a profile, use the default 'resolver' (with
ServerOptions.tenant) or { profileColumn }.
BS309
Warning: deprecated SQL module symbol. A file in supabase/schemas or a
policy uses a function, table, column or claim that a SQL module in sql.modules
deprecated or removed, such as better_supabase.current_org_id() or the
org_id claim. A deprecated symbol keeps a compatibility wrapper for at least
one minor release; a removed one fails at run time. The message names the
replacement. A renamed column such as memberships.org_id also counts
unqualified (m.org_id) in a statement that names its table. Block files and
migrations are skipped, and so is a claim the config maps in claims. Tables
and columns of a module in adopt or custom mode are the app's names and are
never reported.
BS310
Warning: duplicate block trigger. A table has bs_updated_at and another
trigger whose function looks like updated_at, moddatetime or touch, or
bs_audit and another audit trigger, so both run on every write. Drop the
older trigger, or call track_updated_at() or audit() with
replace_trigger => true in a migration.
BS311
Warning: SQL module behind its current version. A module file's
@bs-module line, or its row in better_supabase.modules on a live database,
records an older version than this release ships. Run better-supabase sql upgrade, which writes the forward steps into a migration and rewrites the
files, then create the schema migration. BS304 skips files this check reports.
BS312
Error: block schema exposed through the Data API. [api] schemas in
supabase/config.toml lists better_supabase or a schema a module in sql.modules
sets. Block schemas hold internal tables and helpers that authenticated can
execute so policies can call them; exposing the schema makes them callable over
REST and RPC as well. Remove the schema from [api] schemas and from the
exposed schemas in the dashboard, and set sql.modules.<module>.api to an
exposed schema such as api: sql add writes security invoker entry points
there for the module's client functions, and rpcTransport(supabase, { schema: "api" }) calls them. Doctor reads [api] schemas for every exposure check and falls
back to schemas in better-supabase.config.ts without a config.toml.
BS313
Warning: rate limits not wired to PostgREST. The rate-limit module is in
sql.modules, but on the live database pgrst.db_pre_request for the
authenticator role is unset, or names a function whose body doesn't call
better_supabase.check_request(), so Data API writes are never counted. Apply
the migration better-supabase sql data writes, which sets the hook when no
other one is set, or call check_request() from your own pre-request
function. Doctor needs a database connection for this check. With
sql.modules["rate-limit"].options.preRequest set to false, the app calls
check_request() itself and BS313 does not run.
BS314
Warning: migration-only block option. A module in sql.modules sets an option
that only exists to match a schema you are adopting: tokenStorage: "plain"
for invitations, secretStorage: "column" or an eventIdType or runIdType
other than text for webhooks-out, a blockSource or defaultSource for
the outbox, or values for the audit log. The config only accepts them with
mode: "adopt". Move the data to the managed default (hash the tokens, move
the secrets into Vault), then remove the option.
BS315
Warning: table without an audit trigger. The audit module is in
sql.modules, and a table in schemas has no bs_audit trigger, nor another
trigger that calls better_supabase.audit_row_change(), so its inserts,
updates and deletes are not in the audit log. Register it with
select better_supabase.audit('public.customers') in a schema file. Doctor
skips the block schemas and the tables SQL modules adopt; list other tables in
sql.modules.audit.options.exempt as schema.table globs, where * matches any run
of characters:
export default defineConfig({
sql: {
modules: {
audit: { options: { exempt: ["public.*_archive", "public.sessions"] } },
},
},
});BS316
Warning: legacy migra diff engine. The project keeps declarative schemas
in supabase/schemas or lists sql.modules, but supabase/config.toml has
no [experimental.pgdelta] enabled = true, so the Supabase CLI diffs the
schema with migra. Migra drops grants, comments and security_invoker on
views, and runs only while the stack is stopped. Projects from supabase init
and better-supabase init use pg-delta. To switch, add the table, remove
[db.migrations] schema_paths (pg-delta orders files by dependency), and
create migrations with supabase db schema declarative sync instead of
supabase db diff:
[experimental.pgdelta]
enabled = trueBS317
Warning: bulk grant in a pg-delta schema file. Under pg-delta, a
grant ... on all tables in schema, on all routines in schema or on all sequences in schema statement in supabase/schemas that reaches anon,
authenticated or public has no dependency position. pg-delta can run it after the per-object revokes that narrow a
function or table, which re-opens them to anon and authenticated. Grant
each object by name next to its definition:
revoke execute on function public.recalculate_totals(uuid) from public, anon;
grant execute on function public.recalculate_totals(uuid) to authenticated;For objects created later, use Supabase default privileges instead of a bulk
grant: alter default privileges in schema public grant select on tables to authenticated;. The grants module writes
per-table grants from expose.
BS318
Warning: catalog loop in a pg-delta schema file. A do block in
supabase/schemas that reads information_schema or pg_catalog (pg_class,
pg_tables, ...) and executes statements runs, under pg-delta, before the
tables it looks for exist. It creates nothing, and the diff never sees the
triggers, grants or policies it was meant to add. Write the statements as
static SQL, one per table, or call a per-table function once per table, such
as select better_supabase.audit('public.invoices'); or
select better_supabase.track_updated_at('public.invoices');.
BS319
Error: SQL alters a reserved role. A schema file, SQL module or migration
alters, drops or changes the memberships of a role that supautils reserves on
Supabase: supabase_admin, supabase_auth_admin, supabase_storage_admin,
supabase_functions_admin, supabase_read_only_user,
supabase_realtime_admin, supabase_replication_admin, supabase_etl_admin,
dashboard_user and pgbouncer. The statement works on a local superuser
connection and fails on a hosted project. authenticator, authenticated,
anon and service_role accept alter role ... set and reset, such as
alter role authenticator set pgrst.db_pre_request = '...', and nothing
else. Granting privileges on objects to a reserved role
(grant usage on schema auth to supabase_auth_admin) is not flagged. Create
your own role for anything else.
BS320
Warning: table without the session policy. The sessions module is in
sql.modules, and a table with RLS in schemas has no restrictive policy
that calls better_supabase.session_active(), so a token whose session was
signed out, or whose user was banned or deleted, keeps reaching the table
until it expires. Set sql.modules.sessions.options.policies to true and
run better-supabase sql sync to write the policy on every table, or add it
by hand. Doctor skips the block schemas and the tables SQL modules adopt; list
other tables in sql.modules.sessions.options.exclude as schema.table
globs. See Ended sessions.
BS321
Warning: extension missing from the migrations under pg-delta. A
create extension in supabase/schemas, a SQL module's file included, that
no migration in supabase/migrations repeats. pg-delta can leave an
extension that owns its own schema, such as pgmq, out of the generated
migration, so a database built from the migrations (a branch, CI or
production) lacks it. For a module that owns such an extension, run
better-supabase sql sync: it writes the extension into a migration that
runs before the schema migration. For your own schema files, add
create extension if not exists <name>; to a migration that runs before the
one that uses it. The rule stays quiet until the project has a migration.
BS322
Warning: audit registration of a dropped table. The audit module is in
sql.modules, and better_supabase.audited_tables on the live database has
a row whose table no longer exists. Since the module installs the
bs_audit_forget_dropped event trigger, dropping a table deletes its row,
but a table dropped before that keeps it. Run better-supabase sql sync
and apply the migration better-supabase sql data writes: the module's data
file deletes registrations whose table is gone. Doctor checks this only
against a database.
BS323
Warning: module event trigger missing. A module in sql.modules
creates an event trigger (bs_audit_forget_dropped for audit,
bs_ensure_rls for ensure-rls) that the live database lacks, or, without
a database, that no migration in supabase/migrations creates. Event
triggers belong to no schema, so a schema diff limited to some schemas
(supabase db schema declarative sync -s ...) leaves them out of the
migration, and dropped tables keep their audit registrations or new tables
get no RLS. Run better-supabase sql sync, then better-supabase sql data:
the module's data file repeats its event triggers, so the data migration
creates them. Run the declarative sync without -s from then on.
BS324
Error: shared roles table without a role condition. Under the
provider model, the tenant module's roleThrough and the invitations
module's platformRoles.through name the same roles table, and one of them
has no where. That side then resolves a role id or key of the other kind:
an organization invitation, its accept or update_member_role can grant a
platform role, or a platform invitation a tenant role. better-supabase sql sync refuses the config. Set where on both, a condition on the roles row
{row} such as {row}.scope = 'organization' and {row}.scope = 'system'.
A roleThrough from the provider's roleSources has no where, so set
sql.modules.tenant.options.roleThrough in the config.
BS325
Warning: audit log readable only as the service role. The audit module
defaults eventRoles to service_role and readPolicy to false, so
audit.list() under a user session is 403. Set options.readPolicy: true
and include authenticated in options.eventRoles when the app lists the
log as the signed-in user.
BS326
Warning: admin Auth call without a secret key in env files.
deleteAccount and bs.admin() need SUPABASE_SECRET_KEY. Add it to
.env.local (and the hosted environments), listed without a value in
.env.example.
BS327
Warning: aal2 required without MFA enabled. The app calls
requireAal('aal2'), but [auth.mfa.totp] in config.toml does not set
enroll_enabled and verify_enabled to true, so no user can satisfy the
check.
BS328
Warning: rpc() on a Supabase Lite SQLite driver. [db] driver in
config.toml is sqlite-postgres or sqlite, and a file in
doctor.sources calls $rpc, .rpc() or rpcTransport. Lite has no
rpc() on SQLite, so the server returns an unsupported error without
sending the request. Switch [db] driver to pglite or postgres, or
replace the function with table queries. See
Supabase Lite.
BS329
Warning: feature Supabase Lite does not implement. config.toml sets
[db] driver, so the project runs on Supabase Lite, and a file in
doctor.sources uses Realtime (better-supabase/realtime or .channel())
or Edge Functions (functions.invoke()). Lite implements neither on any
driver. Run the full Supabase stack for that feature, or move the project
with supabase lite upgrade.
Auth config
BS401
Warning: refresh token reuse interval is 0. With refresh token rotation,
server instances that refresh the same session at the same time need a reuse
interval, or all but one of them sign the user out. Use 10, the Supabase
default.
BS402
Info: long access token lifetime. better-supabase verifies tokens locally
instead of calling the auth server, so a revoked session stays valid until its
token expires. Keep jwt_expiry at 3600 or less; the proxy refreshes sessions
for you.
BS403
Info: local stack signs tokens with a shared secret. Hosted projects sign
with asymmetric keys. Run better-supabase keys so
local tokens verify through JWKS, the same as in production.
BS404
Error: Auth hook function grants. For every enabled
[auth.hook.<name>] in supabase/config.toml with a
pg-functions://postgres/<schema>/<function> URI, doctor introspects the
function, even in a schema outside schemas. Auth calls it as
supabase_auth_admin, which needs usage on the schema and execute on the
function. Nobody else should be able to call it: through the Data API, a
client could call the custom access token hook for any user id and read the
claims it adds. The finding lists the SQL that fixes it:
grant usage on schema rbac to supabase_auth_admin;
grant execute on function rbac.custom_access_token_hook(event jsonb) to supabase_auth_admin;
revoke execute on function rbac.custom_access_token_hook(event jsonb) from authenticated, anon, public;Add the statements to the schema file that defines the function and run
supabase db schema declarative sync: pg-delta carries function grants into
the migration. better-supabase doctor --fix-grants prints the block for
every hook at once, ready to paste into that file.
On the legacy migra engine (BS316), supabase db diff drops
function grants, so append the block to the migration it wrote:
supabase db diff -f auth_hook
better-supabase doctor --fix-grants >> supabase/migrations/<timestamp>_auth_hook.sqlWhen the function is the authorization provider's hook
(authorization.tokenHook.function, or a file with its markers.hook line),
the finding lets the provider write the grants instead, with its
grantsCommand when it sets one.
When a file already grants the function, the database is behind, and the
finding names that file. On migra only a migration with the provider's
markers.grants line counts, and the finding says to run
supabase migration up. With pg-delta the declarative schema file that
defines the hook counts too, and the finding says to run
supabase db schema declarative sync, then supabase migration up.
Doctor reads the declarative schemas, then the migrations newest first, and
points findings at the first file that declares the object. With pg-delta it
reads every file in declarative_schema_path (default supabase/schemas) in
name order. On migra it follows [db.migrations] schema_paths, then the files
no entry matches.
A hook whose function doesn't exist is reported at its config.toml line,
because every sign-in fails until it does. Snapshots taken before the hook
was configured skip it; run better-supabase introspect again.
BS405
Warning: custom access token hook shape. Auth runs the hook on every
sign-in and token refresh. Declare it stable and give it
set search_path = '', qualifying every table it reads.
The claims it returns travel with every request, in the session cookie and in
the Authorization header. With --as <uuid>, doctor calls the hook for that
user the way Auth does, as supabase_auth_admin with the user's standard
claims, in a transaction that is rolled back. Two limits apply:
| Limit | Measures | Default |
|---|---|---|
| Whole token | octet_length of all the claims | 2048 bytes, or doctor.claimsLimit without a hook budget |
| The provider's hook budget | octet_length of each of tokenHook.budget.claims | tokenHook.budget.bytes, or doctor.claimsLimit |
Each one is a separate warning, so a token of 1.5 KB whose budget claims fit
the budget passes. When the hook sets tokenHook.budget.truncatedClaim,
doctor also reports that the token lists only some entries, so server checks
for that user need a database lookup. Keep ids and roles in the token and
look everything else up. This needs a direct connection (local stack,
$DATABASE_URL, $SUPABASE_DB_URL or --db-url-stdin).
BS406
Warning: foreign key to auth.users blocks account deletion. A key with
no action or restrict makes auth.admin.deleteUser, and
deleteAccount, fail with "Database error
deleting user" while the user still has rows. Use on delete cascade for data
the user owns (profiles, memberships, notifications) and on delete set null
for records that outlive them (created_by on shared documents, audit rows).
BS407
Error: two authorization hooks. The authorization provider's hook
(authorization.tokenHook) owns the claims in ownedClaims, plus its
tenantClaim. A custom access token hook that also calls
better_supabase.membership_claims (when memberships is owned), or writes
one of those claims itself through jsonb_set or jsonb_build_object, gives
them two sources that drift apart. Use the provider's hook and remove the
extra writes.
The provider's own hook is not reported: doctor recognizes it by
tokenHook.function or by its markers.hook line in the file that creates
it. Claims in registeredClaims, such as features from
better_supabase.feature_claims, are sources of the provider's hook and are
not a second writer. A hook that wraps the provider's hook and then writes one
of those claims again is reported, with the function that already fills it.
BS408
Warning: the entitlements module can't read the provider's memberships.
With entitlements.memberships: "provider" (the default when the config has
authorization), the entitlements block
module reads memberships through the provider's
memberIds template in has_entitlement, which runs as authenticated, and
memberIdsFor in feature_claims, which the provider's hook calls as
supabase_auth_admin. When entitlements is in sql.modules, doctor warns
when:
- the provider can't back the mode:
tenantScopeis not one of its scopes, the scope has noidTypeor one other thanuuid,text,bigintorinteger, ormemberIdsormemberIdsForis missing.sql add entitlementsrefuses to render the module then; setentitlements.memberships: "tenant"to use the tenant module's memberships; - the provider's hook doesn't fill
claims.featuresfrombetter_supabase.feature_claims(tokenHook.registeredClaims), sohasEntitlement()would never see the plan features. Withentitlements.claim: falsethe token carries no features, so doctor skips this check; - no
authorization.membershipstable covers the tenant scope, soentitlement_members()finds no users to sign out after a plan change (an info finding when the provider lists no memberships at all); - a function those templates call is missing from
authorization.requires, isn't executable by the role that calls it, or isn't in the database.
BS409
Warning: the authorization provider and the better-supabase config
disagree. The provider's hook writes the active tenant to
tokenHook.tenantClaim. Doctor warns when claims.tenant names another
claim, because the tenant plugin, the guards and the module's RLS would then
read a claim the hook never writes. It notes when claims.scope is neither
tenant nor the provider's tenantScope, notes when the provider sets
suspension or roleSources while sql.modules.access.model is not
provider (only that model reads them), and reports every entry of the
provider's problems, such as a file version it can't read.
BS410
Warning: HTTP auth hooks. Auth calls an [auth.hook.<hook>] with an
http:// or https:// URI by sending a request signed with the Standard
Webhooks secret in secrets (v1,whsec_<base64>, several joined with |).
Doctor can't read the endpoint's code, so it reports each HTTP hook as an info
finding; verify the request there with authHook from
better-supabase/blocks/webhooks. It also reports:
- an error when
secretsis missing, or when a secret is notv1,whsec_<base64>or decodes to fewer than 24 or more than 64 bytes. The message never quotes the secret; - a warning when the secret is written in
config.tomlinstead of read withsecrets = "env(AUTH_HOOK_SECRET)". A value read fromenv()is checked only when@supabase/configinterpolated it; - a warning when a non-local endpoint uses plain
http.
BS411
Error: the provider access model can't use the authorization provider.
With sql.modules.access.model: "provider", can() and the SQL modules fill
the provider's idsWith template for tenant checks and isPlatform for
platform checks, at its tenantScope. Doctor reports, and sql add refuses:
- a config without
authorization; - a
tenantScopethe provider doesn't declare, a scopeidTypeother thanuuid,text,bigintorinteger, or asql.modules.access.idTypethat differs from it; - a function those templates call that
authorization.requiresdoesn't list, doesn't letauthenticatedexecute, or the database lacks; - a permission key a SQL module checks (
modulePermissionKeysfrombetter-supabase/sqllists them) that the provider doesn't marksqlComplete: true. Its functions decide by role and scope only, so a key with other conditions would be granted everywhere in the scope. Map the action to another key insql.modules.<module>.permissions.
It warns when neither sql.modules.access.functions.canAssign nor the
provider's canAssign is set, because only the service role then assigns
roles. See the access block for the recipe.
It also warns for each optional template an installed module calls that the provider doesn't set, and names the modules:
| Template | Modules | Without it |
|---|---|---|
idsWithFor | invitations, inbox, comments, sso, notifications, connectors, workflow-sdk-world | checks of another user's tenant permissions raise 0A000 or are skipped |
isPlatformFor | invitations | checks of another user's platform permissions answer false or are skipped |
canAssignFor | invitations | the inviter's right to assign the role is not checked again at acceptance |
memberIds | usage | membership checks read the tenant module's memberships table instead |
sql.modules.access.functions.canAssignFor silences the canAssignFor
warning.
BS412
Info: the session cookie encoding differs between server and browser.
@supabase/ssr needs the same cookies.encode on both sides. Doctor reads
the files in doctor.sources: a file that imports better-supabase/client or
calls createBrowserClient is the browser side, and a file that imports
better-supabase/server, next, ssr, hono, orpc, edge or expo, or
calls createServerClient, is the server side. When a file on one side sets
encode: 'tokens-only' and a file on the other side doesn't, the other side
uses the default user-and-tokens: it writes the user object back into the
cookie, or reads a session without one and session.user throws. Set the same
encode in createClient and on the server. See
Encoding.
Env files
BS501
Error: secret in a browser variable. A variable with a public prefix
(NEXT_PUBLIC_, VITE_, EXPO_PUBLIC_, PUBLIC_, NUXT_PUBLIC_) holds a
secret key. Bundlers inline these into client code. Rename the variable and
rotate the key.
BS502
Warning: env file with secrets isn't ignored by git. An env file holds a
secret key or database URL but no .gitignore pattern matches it. Add the
file, for example .env*.local, to .gitignore. .example files are skipped.
Dependencies
BS601
Warning: peer outside its range. An optional peer of better-supabase is
installed at a version outside the range better-supabase declares, for
example @supabase/postgrest-typegen@0.5.0 against >=0.4.0 <0.5. With
strictPeerDependencies the install fails, and the subpath that needs the
peer can break at runtime. Doctor reads the version from the nearest
node_modules above the project root and skips peers that aren't installed.
Install a version in the range, or remove the package if nothing imports it.
Peers lists which subpath needs which peer.
Last updated on