---
title: "Query plan adapter for Convex"
description: "Externalized authorization with Cerbos moves access control into policy files and pushes filtering to the data layer. Learn how the PlanResources API and a new Convex adapter generate efficient, secure queries without row-by-row checks."
author: "Alex Olivier"
date: "2026-05-18T13:43:00.000Z"
canonical: "https://www.cerbos.dev/blog/query-plan-adapter-for-convex"
image: "https://stylish-appliance-1c1cc1c30d.media.strapiapp.com/Query_plan_adapter_for_Convex_4c5fd5a54b.png"
tags: ["documentation","engineering","guide"]
source: "https://www.cerbos.dev/blog/query-plan-adapter-for-convex"
---

# Query plan adapter for Convex

Externalized authorization moves access control out of your application code and into a dedicated policy engine. With Cerbos, you define who can do what in policy files that live alongside your code in version control. At runtime, your application asks [Cerbos](https://www.cerbos.dev/) for a decision rather than implementing the logic itself. Policies can express roles, attributes, conditions, and relationships \- and they can change without redeploying your application.

Individual access checks are straightforward: pass a principal, resource, and action to `checkResources` and get a permit or deny. But listing resources \- "show me everything this user can see" \- is a harder problem. Checking every row one by one means fetching data the user may never be allowed to access. That is wasteful at best and a scaling bottleneck at worst.

The [PlanResources API](https://docs.cerbos.dev/cerbos/latest/api/index.html#resources-query-plan) takes a different approach. Instead of evaluating specific resource instances, Cerbos partially evaluates your policies and returns a query plan: an abstract syntax tree that describes the conditions under which access is granted. This AST can be translated into any query language, pushing authorization filtering down to the data layer where it belongs. The database applies the filter using its own indexes and query engine, rather than your application processing rows it will discard.

[Convex](https://convex.dev/) is a reactive backend platform where your database, server functions, and real-time sync all live in one place. Today we are releasing `@cerbos/orm-convex`, a query plan adapter that translates Cerbos query plans into Convex filter functions.

<p>&nbsp;</p>

## The challenge with Convex

Convex queries use a functional filter API \- `q.eq`, `q.lt`, `q.and`, and so on \- rather than SQL. This means a straightforward SQL translation will not work. The adapter needs to produce composable filter functions that Convex's query engine understands natively.

There is also a gap in what Convex can express at the database level. String operations like `contains` and collection operators like `exists` have no Convex equivalent. The adapter addresses this with a two-tier approach: operations that Convex supports natively become database-level filters, and everything else is evaluated as a post-filter in JavaScript.

<p>&nbsp;</p>

## How it works

`queryPlanToConvex` takes a Cerbos plan and returns up to two functions: a `filter` for the database and an optional `postFilter` for client-side evaluation.

```ts
import { queryPlanToConvex, PlanKind } from "@cerbos/orm-convex";

const plan = await cerbos.planResources({
  principal: { id: "user1", roles: ["USER"] },
  resource: { kind: "task" },
  action: "view",
});

const { kind, filter, postFilter } = queryPlanToConvex({
  queryPlan: plan,
  mapper: {
    "request.resource.attr.status": { field: "status" },
    "request.resource.attr.priority": { field: "priority" },
  },
  allowPostFilter: true,
});

if (kind === PlanKind.ALWAYS_DENIED) return [];

let query = ctx.db.query("tasks");
if (filter) query = query.filter(filter);
let results = await query.collect();
if (postFilter) results = results.filter(postFilter);
return results;
```

### DB-level vs. post-filter operators

| Pushed to Convex DB | Evaluated in JavaScript (post-filter) |
| :---- | :---- |
| `eq`, `ne`, `lt`, `le`, `gt`, `ge` | `contains`, `startsWith`, `endsWith` |
| `and`, `or`, `not` | `hasIntersection` |
| `in`, `isSet` | `exists`, `exists_one`, `all` |

For `and` expressions that mix both tiers, the adapter splits the tree: DB-pushable children go to `filter`, the rest go to `postFilter`. For `or` expressions with any unsupported child, the entire expression is evaluated client-side to avoid returning false positives.

<p>&nbsp;</p>

## Full example

Consider a policy that allows users to view tasks assigned to them, or tasks with a priority above a threshold:

```
# policies/task.yaml
apiVersion: api.cerbos.dev/v1
resourcePolicy:
  resource: task
  version: default
  rules:
    - actions: ["view"]
      effect: EFFECT_ALLOW
      roles: ["USER"]
      condition:
        match:
          any:
            of:
              - expr: request.resource.attr.assignee == request.principal.id
              - expr: request.resource.attr.priority >= 3
```

Inside a Convex query function:

```ts
import { GRPC as Cerbos } from "@cerbos/grpc";
import { queryPlanToConvex, PlanKind } from "@cerbos/orm-convex";
import { query } from "./_generated/server";

const cerbos = new Cerbos("localhost:3592", { tls: false });

export const listTasks = query({
  handler: async (ctx) => {
    const plan = await cerbos.planResources({
      principal: { id: "user1", roles: ["USER"] },
      resource: { kind: "task" },
      action: "view",
    });

    const { kind, filter, postFilter } = queryPlanToConvex({
      queryPlan: plan,
      mapper: {
        "request.resource.attr.assignee": { field: "assignee" },
        "request.resource.attr.priority": { field: "priority" },
      },
    });

    if (kind === PlanKind.ALWAYS_DENIED) return [];

    let q = ctx.db.query("tasks");
    if (filter) q = q.filter(filter);
    let results = await q.collect();
    if (postFilter) results = results.filter(postFilter);
    return results;
  },
});
```

Because this policy only uses comparison operators, the adapter pushes the entire condition to the Convex database layer \- no post-filter is needed and `allowPostFilter` is not required.

### Opting into post-filtering

By default, `queryPlanToConvex` throws if the plan requires a post-filter. This is a deliberate safety choice \- post-filtering means documents are read from the database before the full authorization condition is applied. Pass `allowPostFilter: true` to enable it when your policies need string or collection operators.

If your policies only use comparisons, `in`, `isSet`, and logical combinators, you do not need the flag. The database filter alone will enforce the complete policy.

<p>&nbsp;</p>

## Get started

```shell
npm install @cerbos/orm-convex
```

The full documentation and source are available on [GitHub](https://github.com/cerbos/query-plan-adapters/tree/main/convex). If you have questions, join the [Cerbos community Slack](https://cerbos.dev/slack).  

## Closing thoughts

The adapter is one piece. The policies it translates need somewhere to live, get tested and reach your services.

Try [Cerbos Hub](https://hub.cerbos.cloud/) to version and distribute the policies behind these query plans, or [book a session](https://www.cerbos.dev/workshop) to talk through your Convex schema with our engineers.

Go deeper: [How to adopt externalized authorization](https://solutions.cerbos.dev/how-to-adopt-externalized-authorization) (eBook) for a structured, in-depth approach to navigating the externalized authorization transformation.

## FAQ

### How do you filter Convex query results based on Cerbos authorization policies?

You filter Convex query results with Cerbos by calling the PlanResources API and passing the result to the @cerbos/orm-convex adapter, which turns it into a Convex filter function. The filter runs inside your Convex query rather than in application code, so documents the user cannot access are never returned. Policy stays in Cerbos and the data layer does the filtering.

### What is the difference between PlanResources and CheckResources in Cerbos?

PlanResources answers "what filter finds the resources this principal can access", returning either always allowed, always denied, or a conditional expression tree your adapter converts into a native query. CheckResources answers "can this principal perform this action on these specific resources" for instances you already hold. Fetching every record and running CheckResources on each is the pattern query plans exist to avoid.

### Do I need Cerbos Hub to use the Convex query plan adapter?

No, the Convex query plan adapter calls a Cerbos policy decision point and works without Cerbos Hub. Hub becomes useful once policies need version control, automated testing before rollout, distribution to every decision point without redeploying, and a retained audit trail of decisions. The adapter is unchanged either way.

### How do I install the Cerbos query plan adapter for Convex?

Install the Cerbos Convex adapter from npm as @cerbos/orm-convex, alongside a Cerbos client, either @cerbos/grpc or @cerbos/http. Configuration and a full worked example are in the Convex adapter documentation.
