# Query guards

> Runtime checks that catch unbounded reads, missing organizations, sensitive columns and admin keys in the browser.

Source: https://bettersupabase.com/docs/repository/guards

`guards()` checks every query before it's sent and reports what looks wrong.
It runs before every other plugin, `pre` ones included and whatever the
`use()` order, so it sees the query you wrote, before the memberships and
trash extensions add their filters. It ships in `better-supabase/lint`, next
to the [lint rules](/docs/repository/lint) that catch the same mistakes in
the editor.

```ts
import { guards, recommendedGuards } from "better-supabase/lint";

export const betterSupabase = defineSupabase(schema).use(
  guards({ rules: recommendedGuards() }),
);
```

A `warn` violation goes to `console.warn` and the query runs. An `error`
violation fails the call with `invalid_request`, and nothing is sent.

## Presets [#presets]

| Preset                | Contents                                                                                                                         |
| --------------------- | -------------------------------------------------------------------------------------------------------------------------------- |
| `safeGuards()`        | Data exposure and correctness as errors: `noAdminInBrowser`, `noDeleteWithoutWhere`, `noSensitiveSelect`, `requireTenantContext` |
| `recommendedGuards()` | `safeGuards()` plus the performance rules and `storagePathColumns` as warnings (the default)                                     |
| `strictGuards()`      | Everything as an error, plus `requireMaxAffected`                                                                                |

Spread a preset to change single rules:

```ts
guards({
  rules: {
    ...recommendedGuards(),
    maxLimit: ["error", 200],
    noUnboundedList: "off",
  },
});
```

## Rules [#rules]

| Rule                      | Flags                                                                                                                                                                                                             | Option                                                   |
| ------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------- |
| `noUnboundedList`         | `list` without `limit`                                                                                                                                                                                            |                                                          |
| `maxLimit`                | `limit` above the maximum                                                                                                                                                                                         | maximum, default `1000`                                  |
| `requireOrderByForCursor` | `offset`, or `limit` above 1, without `orderBy`: pages overlap or skip rows. `list` orders by the primary key by default, so only views and keyless tables trigger it                                             |                                                          |
| `maxIncludeDepth`         | Includes nested deeper than the maximum                                                                                                                                                                           | maximum depth, default `3`                               |
| `requireTenantContext`    | A tenant table queried without `context.tenant` or the tenant claim. Service connections are exempt                                                                                                               | claim, default `auth.organization.claim` (`'tenant_id'`) |
| `noAdminInBrowser`        | A service-role connection where `window` and `document` exist                                                                                                                                                     |                                                          |
| `noSensitiveSelect`       | Reading a column listed in [`sensitive`](/docs/cli/config) without `sensitive: true` on the call                                                                                                                  |                                                          |
| `noDeleteWithoutWhere`    | A delete without `where`                                                                                                                                                                                          |                                                          |
| `requireMaxAffected`      | `update` or `delete` by `{ where }` without [`maxAffected`](/docs/repository/writing#limiting-bulk-writes), or with one above the maximum. Writes whose `where` pins the whole primary key are exempt             | maximum, default `1000`                                  |
| `storagePathColumns`      | A write to a `*_url` text column that is listed in [`storagePaths`](/docs/platform/storage#path-columns), or whose value is a Storage URL or matches a bucket's path template. Store the path in `*_path` instead |                                                          |

`noUnboundedList` skips `aggregate()`, and `maxLimit` doesn't count the
extra row `page()` reads to know whether there is a next page.
`requireTenantContext` reads the same claim paths as the
[memberships](/docs/blocks/memberships) extension, so an organization in
`app_metadata` counts. `requireMaxAffected` is only in `strictGuards()`; add it to another preset with `requireMaxAffected: ["error", 500]`.

`noSensitiveSelect` looks at the columns actually selected by reads,
includes too. A `select` that leaves the column out passes; to read it on
purpose:

```ts
await db.contacts.list({ select: ["id", "email"], limit: 50, sensitive: true });
```

Writes don't trip the rule: without a `select`, `create`, `update` and
`upsert` return every column except the sensitive ones. Name the column in
`select`, or pass `sensitive: true`, to get it back.

The repository already refuses a `delete({ where })` that filters nothing on
its own, so `noDeleteWithoutWhere` only catches deletes built by blocks and
extensions. The `no-empty-where` [lint rule](/docs/repository/lint) catches
the call in your editor.

`storagePathColumns` only compares against bucket templates with a literal
part besides `/` (`{organizationId}/{customerId}/logo/{version}.webp`, not
`{userId}/{file}`), so a `website_url` holding `example.com/about` isn't
mistaken for an object.

## Reporting [#reporting]

```ts
guards({
  rules: recommendedGuards(),
  report: (violation) => logger.warn(violation),
});
```

A violation has `rule`, `level`, `table`, `operation` and `message`.
Errors still fail the call after `report` runs.

## Skipping plugins [#skipping-plugins]

`db.$withoutPlugins()` returns the same connection with no plugins at all:
no guards, organization scoping, trash filters or executor wrappers. Use it for
migrations and admin scripts, never for user requests. Pass
`{ keep: ['otel'] }` to keep tracing (or any plugin by name); a name that
isn't installed throws, so a typo can't drop a plugin silently.