Permissions
Jazz's approach to row-level security using relationship-based access controls, and how to build policies of varying complexity.
Permissions control who can read, insert, update, and delete rows. Jazz enforces them server-side using row-level policies defined in permissions.ts. Clients that fail a policy check have their writes rejected and their reads filtered.
Jazz decides permissions per table:
- every read, insert, update, and delete requires an explicit grant
- every omitted operation is denied, including on tables with no policy declarations
For example, declaring allowRead without allowInsert, allowUpdate, or
allowDelete makes that table read-only. A table without any grants returns no
rows to permission-scoped reads and rejects writes on the server. Optimistic
local writes can still appear before the server accepts or rejects them.
Authoring workflow
Permissions are authored in TypeScript.
If we have the following schema:
const schema = {
projects: s.table(
{
name: s.string(),
owner_id: s.uuid(),
},
{ todos: s.reverse("todos", "project") },
),
todos: s.table(
{
title: s.string(),
done: s.boolean(),
parentId: s.uuid().optional(),
projectId: s.uuid().optional(),
owner_id: s.uuid(),
},
{
parent: s.rel("todos", "parentId"),
children: s.reverse("todos", "parent"),
project: s.rel("projects", "projectId"),
shares: s.reverse("todoShares", "todo"),
},
),
todoShares: s.table(
{
todoId: s.uuid(),
user_id: s.uuid(),
can_read: s.boolean(),
},
{ todo: s.rel("todos", "todoId") },
),
};We can define permissions in permissions.ts:
import { schema as s } from "jazz-tools";
import { app } from "./schema.js";
export default s.definePermissions(app, ({ policy, session }) => {
policy.projects.allowRead.always();
policy.projects.allowInsert.always();
policy.todos.allowRead.where({ owner_id: session.user.account });
policy.todos.allowInsert.where({ owner_id: session.user.account });
policy.todos.allowUpdate.where({ owner_id: session.user.account });
policy.todos.allowDelete.where({ owner_id: session.user.account });
});Write your permissions policies in permissions.ts next to schema.ts. By default Jazz looks for both files at your project root — except in SvelteKit projects, where they should live in src/lib/ (the standard location for shared app code).
Run pnpm dlx jazz-tools@alpha validate before publishing to surface missing table or operation grants that will deny access. You do not need to write a schema migration to update permissions policies. Push the updated policies by running pnpm dlx jazz-tools@alpha deploy <appId>.
Apps that do not need user-scoped filtering must still declare every required operation explicitly, using grants such as policy.todos.allowRead.always() and policy.todos.allowInsert.always().
Basic policies
Simple conditions
Use the policy helpers allowRead, allowInsert, allowUpdate, and allowDelete with .where(...) to restrict access based on column values.
s.definePermissions(exampleApp, ({ policy, allOf, session }) => {
policy.todos.allowRead.where({ owner_id: session.user.account });
// Users cannot create todos with different owners
policy.todos.allowInsert.where({ owner_id: session.user.account });
// Users can update their own todos, but only if not already done
policy.todos.allowUpdate
.whereOld(allOf([{ owner_id: session.user.account }, { done: false }]))
.whereNew({ owner_id: session.user.account });
// Users can only delete their own todos
policy.todos.allowDelete.where({ owner_id: session.user.account });
});.always()
Use .always() when an operation should always be permitted. It is equivalent to .where({}).
s.definePermissions(exampleApp, ({ policy }) => {
policy.todos.allowRead.always();
policy.todos.allowInsert.always();
policy.todos.allowUpdate.always();
policy.todos.allowDelete.always();
});.never()
Use .never() when an operation should be impossible. It is equivalent to
.where(anyOf([])).
s.definePermissions(exampleApp, ({ policy }) => {
policy.todos.allowRead.never();
policy.todos.allowInsert.never();
policy.todos.allowUpdate.never();
policy.todos.allowDelete.never();
});Composing policies
Combining conditions (allOf / anyOf)
Combine conditions with allOf (all must match) or anyOf (any can match).
s.definePermissions(exampleApp, ({ policy, allOf, anyOf, allowedTo, session }) => {
// Users can read a todo if they own it, or if it's not done and they can read its project.
policy.todos.allowRead.where(
anyOf([
{ owner_id: session.user.account },
allOf([{ done: false }, allowedTo.read("project")]),
]),
);
});Plain objects in a condition are row predicates. This includes tables with columns such as type,
column, or operation that also appear in Jazz's policy representation. Results from helpers such
as isCreator, allowedTo.*, allOf, and anyOf remain directly composable with those row
predicates.
An object such as { type: "True" } is row data only when the table schema declares a type column.
On a table without that column, Jazz rejects the unbranded policy-shaped value rather than silently
interpreting it as either row data or policy IR. For advanced cases that need manually-authored
policy IR, use raw(...) to opt in explicitly:
definePermissions(app, ({ policy, raw }) => {
policy.documents.allowRead.where(raw({ type: "True" }));
});JWT session claims
When external auth JWTs carry claims, session.where(...) lets you check them directly in permissions without mapping them onto row columns first.
s.definePermissions(exampleApp, ({ policy, anyOf, session }) => {
policy.todos.allowRead.where(
anyOf([{ owner_id: session.user.account }, session.where({ "claims.role": "manager" })]),
);
});Inherited access (allowedTo.*)
A row can inherit its access from a related row. Use
allowedTo.read/insert/update/delete(relationName) to express that inheritance.
The argument is an explicit relationship name declared on the protected table,
using the same names as hopTo. For writer: s.rel("users", "authorId"), use
allowedTo.update("writer"); the stored column authorId is not accepted here.
Reverse names work with the same helpers. Given
authored: s.reverse("posts", "writer") on users,
allowedTo.delete("authored") grants deletion only when at least one referencing
post permits deletion for the current identity. Readability or mere existence
of a referencing post does not grant deletion. No matching reference means no
grant; nullable references contribute no match, and UUID arrays use membership.
The explicit source form allowedTo.deleteReferencing(policy.posts, "writer")
uses the named forward relationship on posts that targets the protected table.
Relationship-name resolution preserves existing operation-policy semantics and
each update rule's old/new row checks. The global helper's TypeScript name check covers the app's declared
relationships; permission compilation also checks the particular rule's table,
and the source direction and target for *Referencing.
s.definePermissions(exampleApp, ({ policy, anyOf, allOf, allowedTo }) => {
// Users can read a todo if it's not done, or if they can read its project.
policy.todos.allowRead.where(anyOf([{ done: false }, allowedTo.read("project")]));
// Users can update a todo if they can update its project and it's not done.
policy.todos.allowUpdate
.whereOld(allOf([allowedTo.update("project"), { done: false }]))
.whereNew(allowedTo.update("project"));
});Share-based access
Sometimes access isn't determined by ownership or a parent relationship, but by a separate "shares" table. Use policy.<table>.exists.where(...) to check whether a matching row exists in another table.
s.definePermissions(exampleApp, ({ policy, anyOf, session }) => {
// Users can read a todo if they own it, or if someone shared it with them.
policy.todos.allowRead.where((todo) =>
anyOf([
{ owner_id: session.user.account },
policy.todoShares.exists.where({
todoId: todo.id,
user_id: session.user.account,
can_read: true,
}),
]),
);
});The callback form (todo) => ... gives you access to the current row, so you can correlate it with rows in other tables.
Update policies: old row vs new row
Update policies can check both the row before the update (.whereOld(...)) and the row
after the update (.whereNew(...)). This is useful when you need to verify that the user had
permission to modify the original row and that the result is also valid.
s.definePermissions(exampleApp, ({ policy, session }) => {
// User can only update their own rows, and the result must still be owned by them
policy.todos.allowUpdate
.whereOld({ owner_id: session.user.account })
.whereNew({ owner_id: session.user.account });
});If you only use .whereOld(...), the same condition is applied to both the old and new row. The
same applies if you only use .whereNew(...). Use both when the old-row and new-row checks differ.
exists runs before a write is applied, so it can be used to prevent some columns from being updated.
s.definePermissions(exampleApp, ({ policy, allOf, session }) => {
policy.todos.allowUpdate.whereOld({ owner_id: session.user.account }).whereNew((updatedTodo) =>
allOf([
{ owner_id: session.user.account },
// `parentId` and `projectId` cannot be updated.
policy.todos.exists.where({
id: updatedTodo.id,
parentId: updatedTodo.parentId,
projectId: updatedTodo.projectId,
}),
]),
);
});Policy enforcement and request context
Every policy is evaluated against a session. On the frontend, this is the authenticated user's session. In a backend handler, create a scoped session per request so queries run with the right identity.
export async function listTodosForRequester(req: Request, res: Response): Promise<void> {
try {
const requester = await client.forRequest(req);
const rows = await requester.all(schemaApp.todos.where({ done: true }));
res.json(rows);
} catch {
sendQueryError(res);
}
}See Server Setup for backend context setup details.
Structural-only client runtimes stay permissive locally so offline reads and writes keep working. Once a compiled bundle is loaded, Jazz enforces explicit grants locally too. Sync servers still reject violating writes, and server-scoped reads are filtered before data is sent to the client.
Check permissions before an action
Use db.canInsert(), db.canRead(), db.canUpdate(), or db.canDelete() when your app needs to
check an action before showing it to the user. Each method returns "allowed", "denied", or
"unknown".
These results are advice, not permission enforcement. A result can become stale, and "unknown"
means Jazz could not give a definite answer. The authority still makes the final decision when the
read or write reaches it.
export async function canReadTodo(db: Db, todoId: string) {
return db.canRead(app.todos, todoId);
}
export async function readTodosWithDeletePermission(db: Db) {
const todos = await db.all(app.todos.select("id", "title").orderBy("title", "asc"));
const advice = await Promise.all(todos.map((todo) => db.canDelete(app.todos, todo.id)));
return todos.filter((_, index) => advice[index] === "allowed");
}
export async function readEditableTodos(db: Db) {
const todos = await db.all(app.todos.select("id", "title").orderBy("title", "asc"));
const advice = await Promise.all(
todos.map((todo) => db.canUpdate(app.todos, todo.id, { title: todo.title })),
);
return todos.filter((_, index) => advice[index] === "allowed");
}
export async function canCreateTodo(db: Db, title: string) {
return db.canInsert(app.todos, { title, done: false });
}Magic columns
Jazz exposes a small set of system-provided magic columns at query time. They do not exist in your
schema, and they are omitted from select("*"), so opt in explicitly when you want them.
Edit metadata columns
Jazz also tracks row authorship and timestamps automatically:
$createdBy— the Jazz principal that created the row$createdAt— when the row was first created$updatedBy— the Jazz principal that last updated the row$updatedAt— when the row was last updated
Jazz always tracks this metadata; select the columns explicitly to include them in query results. Backend writes that are not attributed to a user use the reserved system account and an internal system identity. See System-authored rows for their account, issuer, and originating-node subject.
Authorship-based policies
You can use these edit metadata magic columns directly in permissions.ts. This is useful for simple
"creator can read/edit/delete their own rows" policies without adding explicit owner_id columns.
For the most common case, Jazz also exposes policy.<table>.managedByCreator() and isCreator as
shorthand for the same $createdBy === session.user condition. session.user is the
structured { account, identity: { issuer, subject } } author. Public session.claims also exposes the complete verified
JWT payload, including the raw iss and sub, but those inspectable fields never replace the
canonical author identity. You can still write the raw $createdBy comparison yourself whenever
you want the explicit form. To share ownership across linked identities, compare $createdBy.account with session.user.account instead; whole-author equality distinguishes the identities.
s.definePermissions(exampleApp, ({ policy }) => {
// Sugar for applying `$createdBy === session.user` to read/insert/update/delete.
policy.todos.managedByCreator();
});When you want to reuse the same check inside a larger rule, compose isCreator directly:
s.definePermissions(exampleApp, ({ policy, anyOf, isCreator }) => {
// The same creator condition can still be composed with other rules.
policy.todos.allowRead.where(anyOf([isCreator, { done: true }]));
});For more dynamic sharing or ownership models, prefer explicit tables and relations rather than encoding extra meaning into authorship alone.
Testing permissions
Jazz provides utilities to test permissions in isolation without testing your whole app's logic. See Testing permissions.
Troubleshooting denied access
If a write appears locally but does not persist, wait for
server durability and handle
PersistedWriteRejectedError with code permission_denied. Use
db.onMutationError(...) as a fallback for rejections without an active wait;
see Handling mutation errors.
An empty query result can also mean that read permissions filtered out rows.
As the app developer, check your own configuration:
- Run
pnpm dlx jazz-tools@alpha validateand check that every required table and operation has an explicit grant. For updates and upserts that update an existing row, also check the required read permission. - Check that the grants match the authenticated session and the relevant row values, including both old and new values for updates. Test these cases with permission tests.
- Deploy the intended policies with
pnpm dlx jazz-tools@alpha deploy <appId>and verify the app ID and deployed configuration. Editingpermissions.tslocally does not update the server's policies.
Treat permission_denied as a generic rejection: it does not tell the caller
whether a policy is missing, a predicate failed, or a protected row exists.
Keep these configuration checks in developer tooling and tests; do not expose
protected policies, row values, or row-existence details in client-facing errors.