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"',
}),
| value | means |
|---|---|
null (or left out) | superusers only. Anyone else gets 403 |
'' | anyone, signed in or not |
| an expression | the 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#
| case | answer |
|---|---|
a null rule | 403 Only superusers can perform this action. |
| list | the refused records are filtered out, no error |
| view, update, delete | 404, so the caller cannot tell a refused record from a missing one |
| create | 400 Failed to create record. |
The request in a rule#
| path | value |
|---|---|
@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.method | GET, POST… |
@request.context | default, 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:
:isseton@request.*: the key was sent.@request.body.role:isset = falseforbids a field in an update.:changed: sent, and different from the stored value.:lengthon select, file, relation: the number of values.:eachon select, file, relation: every value matches.tags:each ?= "vip".:loweron 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
emailonly whereemailVisibilityallows; - walks a relation only through records that the target's
listRulelets 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.