Query guards
Runtime checks that catch unbounded reads, missing organizations, sensitive columns and admin keys in the browser.
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 that catch the same mistakes in
the editor.
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
| 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:
guards({
rules: {
...recommendedGuards(),
maxLimit: ["error", 200],
noUnboundedList: "off",
},
});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 without sensitive: true on the call | |
noDeleteWithoutWhere | A delete without where | |
requireMaxAffected | update or delete by { where } without maxAffected, 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, 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 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:
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 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
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
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.
Last updated on