When we founded Cerbos in early 2021, we set out to solve a recurring problem we kept seeing across engineering teams: every service was reinventing authorization. Rules were tangled in code, duplicated across microservices, inconsistently tested, and difficult for product or security teams to review. We wanted a clean, scalable, maintainable way to manage “who can do what on which resource, under which conditions”.
Like many teams, we initially used Open Policy Agent as the foundation. It was mature, open source, flexible, and widely adopted. At the time it looked like the right platform on which to build application-level authorization.
Over time we learned that while OPA is excellent as a general-purpose policy engine (Kubernetes admission control, Terraform checks, CI/CD gates, network controls), that very generality becomes friction for application-level authorization.
Developers didn’t want to learn a new logic language - Rego. Input shapes became brittle. Rules became harder to reason about at scale. The evaluation model wasn’t optimized for app workloads. As our customers’ needs evolved - multi-tenant access control, per-resource context, derived roles, richer outputs - we needed an engine designed specifically for these patterns.
That led us to an important pivot: we moved away from OPA and built our own authorization engine tailored to application workloads. The result was a significant performance boost, a drastically simpler policy-authoring model, clearer separation of concerns, and an authorization experience developers actually enjoy using.
Migrating from OPA + Rego to Cerbos isn’t just replacing one engine with another, it’s adopting a model purpose-built for applications: readable YAML policies, CEL-based conditions, rich outputs, baked-in schemas, predictable evaluation, and an incremental migration path that lets you run Cerbos alongside OPA before fully cutting over.
This guide walks you through the differences, how to translate common patterns, how to update your integration layer, and how to roll out the migration safely.
Before diving into code, it's crucial to understand the philosophical shift from OPA/Rego to Cerbos. Cerbos is built on a few core principles designed for safety, clarity, and maintainability in application authorization.
Resource-centric policies. In Cerbos, policies are organized around resources (invoice, dashboard, user_profile). All rules describing who can access a dashboard live in one file. This locality makes policies easier to find, audit, and reason about compared to scattering logic across role-centric or action-centric Rego files.
Default-deny. Cerbos operates on a "default-deny" principle. Access is only granted if a policy explicitly allows it. There are no ambiguous states; if a rule doesn't match to grant permission, the request is denied. This fail-safe approach eliminates a common class of bugs where a miswritten or incomplete Rego rule could inadvertently grant access.
Schema-driven validation. While Rego accepts arbitrary JSON, Cerbos encourages using JSON Schemas to define the expected structure of your principals and resources. This catches attribute errors early, enables better tooling, and ensures consistency across all your services.
Explicit and declarative. Cerbos policies are written in human-readable YAML. The structure is declarative, making it easy for developers, security teams, and even product managers to understand the logic without needing to be experts in a complex query language.
A typical OPA request looks like:
{
"input": {
"user": { "id": "u123", "roles": ["editor"], "department": "engineering" },
"resource": { "id": "doc789", "type": "report", "department": "engineering", "owner": "u456" },
"action": "approve"
}
}
The application constructs this JSON, calls OPA, and receives a boolean or structured output.
With Cerbos, the interaction pattern is similar, but the request shape is explicit and designed for application authorization. Using an SDK-first example:
from cerbos.sdk.client import CerbosClient
from cerbos.sdk.model import Principal, Resource
client = CerbosClient("http://cerbos:3593")
principal = Principal(
id="u123",
roles=["editor"],
attributes={"department": "engineering"}
)
resource = Resource(
kind="report",
id="doc789",
attributes={
"department": "engineering",
"owner": "u456"
}
)
decision = client.is_allowed(
principal=principal,
resource=resource,
action="approve"
)
allowed = decision
This SDK call wraps the underlying CheckResources API. Cerbos evaluates the request against resource policies, derived roles, schemas and conditions, and returns a structured allow/deny decision.
Below are the most common patterns we see in Rego and how to express them cleanly in Cerbos.
Rego
allow {
input.user.roles[_] == "admin"
}
allow {
input.user.roles[_] == "manager"
input.action == "approve_expense"
}
Cerbos
apiVersion: api.cerbos.dev/v1
resourcePolicy:
resource: expense
version: default
rules:
- name: admin-all
actions: ["*"]
effect: EFFECT_ALLOW
roles: ["admin"]
- name: manager-approve
actions: ["approve_expense"]
effect: EFFECT_ALLOW
roles: ["manager"]
Why this is better
expense resource are colocated. The intent of each rule is immediately obvious.actions: ["*"] for admins is clearer and less error-prone than an implicit wildcard.Rego
allow {
input.action == "edit"
input.resource.owner == input.user.id
}
Cerbos
- name: edit-owner
actions: ["edit"]
effect: EFFECT_ALLOW
roles: ["*"]
condition:
match:
expr: request.resource.attr.owner == request.principal.id
Why this is better
request.principal.attr, request.resource.attr), which can be validated by a JSON Schema. This prevents bugs from typos or missing fields.Complex rules often check a combination of a user's role and their relationship to a resource (e.g., "a tenant_admin can only manage resources within their own tenant").
Rego
allow {
input.user.roles[_] == "tenant_admin"
input.resource.tenant_id == input.user.tenant_id
}
In Rego, this logic must be repeated everywhere it's needed, leading to brittle, copy-pasted rules. Cerbos solves this with derived roles.
Cerbos derived role
apiVersion: api.cerbos.dev/v1
derivedRoles:
name: tenant_context
definitions:
- name: tenant_admin_of_resource # A descriptive name for the role
parentRoles: ["tenant_admin"]
condition:
match:
expr: request.principal.attr.tenant_id == request.resource.attr.tenant_id
Cerbos resource policy
# In a policy for a resource like "invoice" or "project"
apiVersion: api.cerbos.dev/v1
resourcePolicy:
resource: project
version: default
importDerivedRoles:
- tenant_context
rules:
- name: tenant-admin-actions
actions: ["manage", "delete"]
effect: EFFECT_ALLOW
derivedRoles: ["tenant_admin_of_resource"] # Use the derived role here
Why this is better
tenant_admin) is separated from the contextual condition. This makes policies cleaner and easier to maintain.tenant_admin_of_resource can perform manage actions. The complexity is neatly abstracted away.If your Rego policies emit structured data for auditing or UI purposes, Cerbos handles this with the output block.
Cerbos
- name: confidential-access-rule
actions: ["view"]
effect: EFFECT_ALLOW
roles: ["auditor"]
condition:
match:
expr: request.resource.attr.classification == "confidential"
output:
when:
ruleActivated: |
{
"message": "Access granted to confidential document.",
"auditId": request.auxData.jwt.claims.auditId
}
Why this is better
auxData.
A safe migration follows a clear, phased process. Instead of a "big bang" cutover, we recommend an incremental strategy.
resourcePolicy per resource kind and identifying opportunities for derivedRoles.
Large monolithic policies.
Break them down. A giant Rego file often maps to many small, focused resourcePolicy files in Cerbos, which are much easier to manage.
Complex Rego logic.
Logic involving loops, comprehensions, or virtual documents can often be simplified by restructuring the input data or using Cerbos derivedRoles and CEL expressions.
Inconsistent input data.
Rego's flexibility can hide inconsistencies in the data sent by different services. Cerbos's JSON Schemas force you to fix these inconsistencies, resulting in a more robust system.
Embedded business logic.
If your Rego contains business logic beyond just authorization, refactor that logic back into the application layer. Let your application gather and prepare attributes, and let Cerbos make the clean, final authorization decision.
Migrating from OPA + Rego to Cerbos is an opportunity to adopt a simpler, more transparent, and more scalable authorization architecture. Cerbos gives you a developer-friendly policy format, strong attribute validation, contextual roles, declarative conditions, and rich outputs, all designed specifically for the challenges of application-level authorization.
By converting your Rego logic into resource-centric Cerbos policies, running both systems in parallel to validate parity, and gradually cutting over, you can modernize your authorization stack with confidence and reduce long-term complexity. You'll be left with a system that is not only faster and more secure, but also far easier for your entire team to understand and maintain.
For a comparison of Cerbos and OPA across policy language, developer experience, architecture, performance, and policy management - refer to our deep-dive.
If you’re looking to enforce fine-grained, contextual, and continuous authorization across apps, APIs, AI agents, MCPs, services and workloads - give Cerbos a try.
If you’re curious how Cerbos could fit into your architecture or have specific requirements to discuss, feel free to book a call with a Cerbos engineer for a free 1:1 session.
Book a free Policy Workshop to discuss your requirements and get your first policy written by the Cerbos team
Join thousands of developers | Features and updates | 1x per month | No spam, just goodies.