Security Model
Vincent Depassier · August 29, 2026
PraxQL — Security Model
Every request passes through six independent layers before any data comes back. They are worth understanding as a set, because each one fails differently and the differences are what you design your access model around.
Layer 1 — Authenticating the key
Request arrives
→ Extract the Bearer token from Authorization, or the x-api-key header
→ Match it against the stored credentials
→ Check the credential is Active
→ Check ExpiresAt has not passed, when set
→ Check the principal is ActiveAnything failing here is 401 UNAUTHORIZED.
The key itself is never stored — only a one-way hash of it. That is why the portal shows a new key exactly once: nothing in the platform can recover it afterwards, and a leaked backup does not hand anyone a working credential.
Revoking a key is not instantaneous. Credential lookups are cached for up to two minutes, so a revoked key can still be accepted briefly. Plan for that window when you rotate under pressure; suspending the principal has the same delay, and only ending an end user's sessions takes effect immediately.
Layer 2 — Workspace isolation, structurally
Table GUIDs from refs are not resolved on their own. The authenticated workspace is bound into the lookup itself, so a GUID belonging to another workspace simply is not found.
This is worth stating precisely because of what it rules out: there is no cross-tenant policy to misconfigure and no rule an administrator can forget to apply. A key issued for workspace A cannot reach workspace B's data because the request that would return it cannot be expressed.
Layer 3 — Table permissions
Each credential carries table scopes.
Access level | Allows |
|
|
|
|
| both |
A table with no scope answers `403 SCOPE_VIOLATION`, not `404`, and the message does not confirm whether the table exists. A 404 would let someone map your workspace by probing GUIDs.
Where the scope comes from
There are two sources, and which one applies depends on who is calling:
Caller | Permissions come from |
A server or publishable key ( | the credential's own table and column scopes |
An end user with a session JWT | the scopes attached to that user's gateway roles, merged as a union |
The role path matters: an end user holding two roles gets the union of what both allow, so removing a permission means removing it from every role that grants it, not just the obvious one.
Layer 4 — Column permissions
Inside an allowed table, each operation on each column is separate:
Permission | Controls | Default on a new column scope |
| may appear in | on |
| may appear in | on |
| may appear in | on |
| may appear in | off |
| may be used with an aggregate function | off |
| may be set by a mutation | off |
They are separate because they leak differently. A column somebody may filter on but not read still tells them something — filter by salary, count the rows, and you have learned a range without ever selecting the column. Grant CanFilter deliberately, not as a side effect of granting CanRead.
A table scope with no column scopes defined allows every column. Define column scopes when a table holds something specific that should not travel — but note the asymmetry: defining them switches you from "everything allowed" to the defaults above, where writing, grouping and aggregating are off.
A column scope can also carry a default value template used on insert. It supports claim placeholders ({{claim:sub}}), it is injected server-side, and the caller cannot override it — which is how a row gets stamped with its owner without trusting the client to send one.
Layer 5 — Row filters
A row filter lives on the table scope and is injected into the where of every request touching that table. The caller cannot see it, remove it, or write around it — their conditions are ANDed with it.
{ "field": "ClientId", "op": "eq", "value": "client-guid-here" }Every request this key makes carries that condition. The caller does not see other clients' rows, and cannot learn how many other clients exist.
Reads and writes, together or apart
A scope holds two filters, and the second one is optional:
Filter | Applies to |
Row filter |
|
Write row filter |
|
Leave the write filter empty and one rule governs everything: a row a caller cannot read is a row they cannot modify. That is the right default, and for a single-tenant-per-key setup it is all you need.
Set it when people share a container. A chat channel scoped Channel = X with write access lets every member edit and delete every other member's messages, because one filter only decides which rows are matched. Splitting them expresses the rule you actually wanted — read the whole room, change only what is yours:
read filter → Channel eq X
write filter → CREATEDBY eq {{claim:sub}}`CREATEDBY` is the natural anchor. The gateway stamps it from the caller's verified token on every insert and refuses to let a client set it, so it is the one column a caller can never forge.
An UPDATE is verified again after it runs: if the rows it touched do not satisfy the write filter, the transaction is rolled back rather than committed.
Filters that resolve from the token
On a role scope, either filter can read a claim from the end user's token instead of holding a fixed value:
{ "field": "OwnerUserId", "op": "eq", "valueFromClaim": "sub" }That resolves to the authenticated end user's own id at request time, which is what makes per-user isolation automatic rather than something every endpoint has to remember.
There is a shortcut for the common case. Setting the filter template to the literal __SELF__ tells the platform to find the table's first end-user column itself and build exactly the filter above. You get per-user isolation without naming a column — and without the filter breaking when somebody renames it.
If a table has no end-user column,
__SELF__has nothing to bind to. Add the column before relying on it.
Layer 6 — Guardrails
Some limits are absolute. No scope, plan or setting raises them:
Guardrail | Ceiling |
Table refs per request | 20 |
Columns in | 100 |
| 50 |
Condition nesting depth | 5 |
| 200 characters |
Rows per query | 1,000 |
Relation depth | 5 |
Rows per insert | 100 |
Columns in an update | 50 |
Query and mutation timeout | 30 seconds |
Others are per-scope, and start lower than the ceiling. A newly created table scope is issued with:
Setting | Credential scope | Role scope |
|
|
|
Max limit override | 200 rows | 100 rows |
Max relation depth | 2 | 2 |
Allow relations | on | on |
Allow aggregations | off | off |
Allow schema introspection | off | off |
So a real credential does not get the absolute ceiling — and an end user's role starts lower still. If a query returns fewer rows than you asked for, read meta.limit: that is the scope talking, not the platform.
An override can be moved up to the absolute ceiling for that scope, never past it.
Masking, after execution
Once rows come back, each column is masked according to its scope's MaskingRule:
Rule | Effect |
| no masking |
| first and last character: |
| local part hidden: |
| last four digits: |
|
|
Masking runs after filtering, so the comparison still happens against the real value. Filtering on a masked column keeps working — which is the point, and also the reason CanFilter deserves its own thought.
Audit trail
Every request, successful or not, is logged asynchronously after the response is sent: workspace, credential and principal, end-user id for JWT callers, the full query body, status code, duration, rows returned, source IP and user agent, and the error when there was one.
Logs are in the Logs tab and cannot be deleted.
What this model does not cover
These six layers govern data. Two other axes apply at the same time and are configured elsewhere: which documents a credential may touch (Docs scopes) and which MCP tools it may call at all (tool permissions). None of the three can widen what another denies — granting a tool group does not grant the data it would read.