# doctor

> Security, performance and drift checks for your database, config.toml and env files.

Source: https://bettersupabase.com/docs/cli/doctor

```bash
pnpm better-supabase doctor
```

`doctor` introspects the database the same way [`gen`](/docs/cli/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](https://supabase.com/docs/guides/database/database-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](/docs/cli/gen#from-a-generatormetadata-document)) |
| `--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](#bs209))                                                                                                                                                                                        |
| `--explain <tables>`   | Plan these tables under RLS ([BS212](#bs212))                                                                                                                                                                                                                      |
| `--as <uuid>`          | With `--explain`: plan as this authenticated user. Also measures the claims the custom access token hook returns for them ([BS405](#bs405))                                                                                                                        |
| `--claims <json>`      | With `--explain`: plan with these JWT claims                                                                                                                                                                                                                       |
| `--fix-grants`         | Print the grant and revoke SQL that [BS404](#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 [#in-ci]

`--format github` writes workflow annotations, so findings show up on the pull
request diff:

```yaml
- run: pnpm better-supabase doctor --format github --strict
```

The `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:

```yaml
- run: pnpm better-supabase doctor --format sarif --out doctor.sarif
- uses: github/codeql-action/upload-sarif@v3
  if: always()
  with:
    sarif_file: doctor.sarif
```

To keep the workflow step to `better-supabase doctor`, put the CI settings in
the config's [`ci` environment](/docs/cli/config#environments), which the CLI
applies when `$CI` is set:

```ts title="better-supabase.config.ts"
export default defineConfig({
  $ci: { doctor: { format: "sarif", output: "doctor.sarif", strict: true } },
});
```

`doctor --json` prints one report that follows
[`doctor-report-v1.json`](https://unpkg.com/better-supabase/schemas/doctor-report-v1.json).

## Ignoring checks [#ignoring-checks]

```ts title="better-supabase.config.ts"
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](/docs/extending/authorization-providers) 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](#bs206) reports it.

The full `doctor` block, generated from the `DoctorConfig` type:

| Name | Type | Default | Description |
| --- | --- | --- | --- |
| `ignore?` | `readonly string[]` |  | Finding codes to skip, e.g. `['BS204']`. |
| `only?` | `readonly string[]` |  | Run only these checks, e.g. `['BS100', 'BS304']`. |
| `strict?` | `boolean` |  | Treat warnings as errors. |
| `format?` | `"github" \| "sarif" \| "text"` |  | Report format: `text` (default), `sarif` (code scanning) or `github` (workflow annotations). `--json` still wins. |
| `output?` | `string` |  | Write the report to this file. |
| `sources?` | `readonly string[]` |  | App source files doctor scans for API use (BS210). Globs relative to the root. Defaults to `['src/**\/*.{ts,tsx}']`. |
| `policyHelperLimit?` | `number` |  | How many policies a security definer helper may appear in before BS206 asks for an inlinable `language sql stable` function. Defaults to 5. |
| `claimsLimit?` | `number` |  | The BS405 limit in bytes. When the authorization provider's token hook has a budget, it replaces that budget and the whole token keeps 2048. Otherwise it limits the whole token's claims (default 2048). |

## Supabase advisors [#supabase-advisors]

### BS100 [#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-ref` or `source.projectRef`): the Management
  API's `GET /v1/projects/{ref}/advisors/security`, the same data the dashboard
  shows.
* **Local stack, `$DATABASE_URL`, `$SUPABASE_DB_URL` or `--db-url-stdin`:** [splinter](https://github.com/supabase/splinter),
  the SQL behind the advisors. Doctor downloads `splinter.sql` at a pinned
  commit, checks its SHA-256, caches it in
  `node_modules/.cache/better-supabase` and 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](/docs/cli/gen#hosted-projects-without-a-database-password)),
not a classic token with access to your whole account.

### BS200 [#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](#bs207) and [BS216](#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 [#security]

### BS103 [#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 [#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`](/docs/guides/data-api-grants)
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](#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 [#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`](/docs/testing#tenant-isolation). The tenant table
itself (the one the tenant column references) is skipped.

### BS108 [#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](/docs/auth/mfa-sso).

### BS109 [#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 [#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:

```sql
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 [#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 [#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 [#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 [#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 [#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 [#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`](/docs/guides/data-api-grants) 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](#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 [#performance]

### BS204 [#bs204]

**Warning: tenant column without an index.** With the
[tenant plugin](/docs/plugins), every query filters on the tenant column. Add
an index that starts with it.

### BS205 [#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:

```sql
create policy notes_member on public.notes for select to authenticated
  using (organization_id in (select private.user_organization_ids()));
```

### BS206 [#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 [#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](#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 [#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 [#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 [#bs210]

**Warning: aggregates used while PostgREST disables them.** PostgREST
answers `aggregate()`, `_sum`, `_avg`, `_min` and `_max` includes and list
[`facetCounts`](/docs/platform/list#facet-counts) 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:

```sql
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 [#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 [#bs212]

**Info: RLS plan for a table.** `--explain customers,notes` runs, for each
table, in a transaction that is rolled back:

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

```text
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 [#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:

```sql
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 [#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](/docs/extending/authorization-providers)
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 [#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 [#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](#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 [#bs217]

**Warning: tenant foreign key that can cross tenants.** With the
[tenant plugin](/docs/plugins), 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:

```sql
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 [#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:

```sql
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 [#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 `?`:

```sql
create index notes_meta_idx on public.notes using gin (meta jsonb_path_ops);
```

### BS220 [#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 [#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 [#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](/docs/cli/config#options)), or add BS222
to `doctor.ignore` when the values stay small.

## Drift [#drift]

### BS301 [#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 [#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 [#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 [#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 [#bs305]

**Warning: live query table without change broadcasts.** A table in
`realtime.tables` has no `bs_realtime` trigger, so
[live queries](/docs/frontend/live-queries) never hear about its changes. Run
`better-supabase sql add realtime-tables`, then
`supabase db schema declarative sync`.

### BS306 [#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 [#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 [#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 [#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 [#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 [#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 [#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 [#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 [#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 [#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:

```ts title="better-supabase.config.ts"
export default defineConfig({
  sql: {
    modules: {
      audit: { options: { exempt: ["public.*_archive", "public.sessions"] } },
    },
  },
});
```

### BS316 [#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`:

```toml title="supabase/config.toml"
[experimental.pgdelta]
enabled = true
```

### BS317 [#bs317]

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

```sql
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](/docs/guides/data-api-grants) writes
per-table grants from `expose`.

### BS318 [#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 [#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 [#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](/docs/auth/account-deletion#ended-sessions).

### BS321 [#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 [#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 [#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 [#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 [#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 [#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 [#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 [#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](/docs/platform/lite).

### BS329 [#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 [#auth-config]

### BS401 [#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 [#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 [#bs403]

**Info: local stack signs tokens with a shared secret.** Hosted projects sign
with asymmetric keys. Run [`better-supabase keys`](/docs/cli/local#keys) so
local tokens verify through JWKS, the same as in production.

### BS404 [#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:

```sql
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](#bs316)), `supabase db diff` drops
function grants, so append the block to the migration it wrote:

```bash
supabase db diff -f auth_hook
better-supabase doctor --fix-grants >> supabase/migrations/<timestamp>_auth_hook.sql
```

When 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 [#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 [#bs406]

**Warning: foreign key to `auth.users` blocks account deletion.** A key with
`no action` or `restrict` makes `auth.admin.deleteUser`, and
[`deleteAccount`](/docs/auth/account-deletion), 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 [#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 [#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](/docs/blocks/entitlements) 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: `tenantScope` is not one of its scopes,
  the scope has no `idType` or one other than `uuid`, `text`, `bigint` or
  `integer`, or `memberIds` or `memberIdsFor` is missing. `sql add
  entitlements` refuses to render the module then; set
  `entitlements.memberships: "tenant"` to use the tenant module's memberships;
* the provider's hook doesn't fill `claims.features` from
  `better_supabase.feature_claims` (`tokenHook.registeredClaims`), so
  `hasEntitlement()` would never see the plan features. With
  `entitlements.claim: false` the token carries no features, so doctor skips
  this check;
* no `authorization.memberships` table covers the tenant scope, so
  `entitlement_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 [#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 [#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`](/docs/standards/webhooks). It also reports:

* an error when `secrets` is missing, or when a secret is not
  `v1,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.toml` instead of read with
  `secrets = "env(AUTH_HOOK_SECRET)"`. A value read from `env()` is checked
  only when `@supabase/config` interpolated it;
* a warning when a non-local endpoint uses plain `http`.

### BS411 [#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 `tenantScope` the provider doesn't declare, a scope `idType` other than
  `uuid`, `text`, `bigint` or `integer`, or a `sql.modules.access.idType`
  that differs from it;
* a function those templates call that `authorization.requires` doesn't list,
  doesn't let `authenticated` execute, or the database lacks;
* a permission key a SQL module checks (`modulePermissionKeys` from
  `better-supabase/sql` lists them) that the provider doesn't mark
  `sqlComplete: 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 in `sql.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](/docs/blocks/access) 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 [#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](/docs/auth/sessions#encoding).

## Env files [#env-files]

### BS501 [#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 [#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 [#dependencies]

### BS601 [#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](/docs/getting-started/peers) lists which subpath needs which peer.