DocsDevelopers

Rules and filters

API rules decide who lists, views, creates, updates and deletes each record; filters narrow a list; both use PocketBase's filter language, compiled to SQL.

Rules are the security boundary of an app. Each collection carries one rule per action, written in PocketBase's filter language. The records API compiles the rule and the client's filter into one SQL expression, so a record the rule refuses never leaves the database.

One rule per action#

orders: base({
    fields: { owner: relation('users'), status: select(['open', 'done']), total: number() },
    listRule: 'owner = @request.auth.id',
    viewRule: 'owner = @request.auth.id',
    createRule: '@request.auth.id != "" && @request.body.owner = @request.auth.id',
    updateRule: 'owner = @request.auth.id && @request.body.owner:isset = false',
    deleteRule: 'owner = @request.auth.id && status = "open"',
}),
valuemeans
null (or left out)superusers only. Anyone else gets 403
''anyone, signed in or not
an expressionthe record must match it, with @request filled from the caller

On an auth collection, authRule runs at sign in (the template uses disabled = false to switch a user off), and manageRule lets a record manage others: change their email or verified without the old password.

A superuser skips every rule, as in PocketBase.

What the caller sees on a refusal#

caseanswer
a null rule403 Only superusers can perform this action.
listthe refused records are filtered out, no error
view, update, delete404, so the caller cannot tell a refused record from a missing one
create400 Failed to create record.

The request in a rule#

pathvalue
@request.auth.<field>the signed in record. Any field, relations included: @request.auth.team.active = true
@request.body.<field>the submitted data, after field+ and field- are applied
@request.query.<key>, @request.headers.<key>the query string, the headers (keys snake cased: X-Token is x_token)
@request.methodGET, POST…
@request.contextdefault, batch, mcp (a call from an AI through the MCP server), and others
@collection.<name>.<field>another collection, joined: @collection.members.user ?= @request.auth.id

A key the request does not carry is NULL.

Modifiers:

  • :isset on @request.*: the key was sent. @request.body.role:isset = false forbids a field in an update.
  • :changed: sent, and different from the stored value.
  • :length on select, file, relation: the number of values.
  • :each on select, file, relation: every value matches. tags:each ?= "vip".
  • :lower on any field or request value.

Operators#

= != > >= < <= ~ !~, each with an "any of" twin ?=, ?!=, ?~… A multiple value (a multi select, a multi relation, a back relation) must match for ALL its values with the plain operator, and for at least one with the ? twin.

~ is "contains": name ~ "ann" is LIKE '%ann%'. '' and NULL are equal for = and !=. Join with && and || and parentheses. The full grammar, macros and functions: Filter syntax.

Filters from the client#

A list request carries a filter on top of the rule. For a caller who is not a superuser, the filter:

  • cannot use @request. or @collection.;
  • cannot read a hidden field, and sees email only where emailVisibility allows;
  • walks a relation only through records that the target's listRule lets it see.

Bind every value#

A value in a filter goes through {:name} parameters, never through string concatenation.

import { bindFilter } from 'locosdk/client'

const f = bindFilter('status = {:s} && total > {:min} && created >= {:since}', { s: status, min: 100, since: new Date('2026-01-01') })
const page = await client.collection('orders').getList(1, 50, { filter: f, sort: '-created' })

Strings are quoted and escaped, numbers and booleans stay raw, a Date becomes "YYYY-MM-DD HH:MM:SS.sssZ". On the server the same function is filter(expr, params) from locosdk/server. Field names are code literals, never user input: an unknown field is an error that names it.

Keep something from an AI#

The MCP server sends each tool call through the same records API with @request.context = "mcp". A rule can then refuse the AI what the UI allows:

deleteRule: 'owner = @request.auth.id && @request.context != "mcp"',

See The MCP server.

Rules do not cover raw SQL#

app.records in a hook is the trusted service: no rules. app.db.query(...) is raw SQLite: no rules. A custom route that uses either one IS the access control. Guard it with requireAuth(...) and check what the caller is allowed to do. See Server: hooks, routes, crons.

Limits#

A client filter is at most 3500 characters and 200 expressions. A sort takes at most 8 fields.

This page as markdown