Auth & Permissions

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:

schema.ts
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:

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.

permissions.ts
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({}).

permissions.ts
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([])).

permissions.ts
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).

permissions.ts
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.

permissions.ts
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.

permissions.ts
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.

permissions.ts
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.

permissions.ts
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.

permissions.ts
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.

handler.ts
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.

app.ts
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.

permissions.ts
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:

permissions.ts
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:

  1. Run pnpm dlx jazz-tools@alpha validate and 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.
  2. 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.
  3. Deploy the intended policies with pnpm dlx jazz-tools@alpha deploy <appId> and verify the app ID and deployed configuration. Editing permissions.ts locally 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.

On this page