Skip to main content

Guide

Cerbos vs Permit.io vs OPA (2026)

Compare Cerbos, Permit.io, and OPA for authorization in JavaScript applications. Policy-based access control, RBAC, ABAC, and which authorization service to.

·PkgPulse Team·
0

TL;DR

Cerbos is the open-source authorization engine — policy-as-code in YAML, self-hosted, decoupled from your app, supports RBAC/ABAC/PBAC, fast local evaluation. Permit.io is the authorization-as-a-service platform — visual policy editor, SDKs, RBAC/ABAC/ReBAC, audit logs, no infrastructure to manage. OPA (Open Policy Agent) is the general-purpose policy engine — Rego policy language, works for API authorization, Kubernetes, infrastructure, the CNCF standard. In 2026: Cerbos for self-hosted policy-as-code authorization, Permit.io for managed authorization with a UI, OPA for general-purpose policy enforcement.

Key Takeaways

  • Cerbos: Open-source — YAML policies, self-hosted, RBAC/ABAC, sidecar pattern
  • Permit.io: SaaS — visual editor, SDKs, RBAC/ABAC/ReBAC, audit logs
  • OPA: Open-source — Rego language, general-purpose, CNCF graduated, Kubernetes-native
  • Cerbos is purpose-built for application authorization
  • Permit.io requires no infrastructure management
  • OPA is the most versatile (API, K8s, Terraform, data filtering)

Cerbos

Cerbos — policy-as-code authorization:

Define policies

# policies/resource_package.yaml
apiVersion: api.cerbos.dev/v1
resourcePolicy:
  resource: "package"
  version: "default"
  rules:
    # Anyone can read published packages:
    - actions: ["read", "list"]
      effect: EFFECT_ALLOW
      roles: ["*"]
      condition:
        match:
          expr: request.resource.attr.status == "published"

    # Authors can update their own packages:
    - actions: ["update"]
      effect: EFFECT_ALLOW
      roles: ["author"]
      condition:
        match:
          expr: request.resource.attr.ownerId == request.principal.id

    # Admins can do everything:
    - actions: ["*"]
      effect: EFFECT_ALLOW
      roles: ["admin"]

    # Authors can delete their own draft packages:
    - actions: ["delete"]
      effect: EFFECT_ALLOW
      roles: ["author"]
      condition:
        match:
          all:
            of:
              - expr: request.resource.attr.ownerId == request.principal.id
              - expr: request.resource.attr.status == "draft"

Node.js SDK

import { GRPC as Cerbos } from "@cerbos/grpc"

const cerbos = new Cerbos("localhost:3593")

// Check permission:
const decision = await cerbos.checkResource({
  principal: {
    id: "user-123",
    roles: ["author"],
    attr: { department: "engineering" },
  },
  resource: {
    kind: "package",
    id: "pkg-456",
    attr: {
      ownerId: "user-123",
      status: "published",
    },
  },
  actions: ["read", "update", "delete"],
})

console.log(decision.isAllowed("read"))    // true
console.log(decision.isAllowed("update"))  // true (owner)
console.log(decision.isAllowed("delete"))  // false (published)

Express middleware

import { GRPC as Cerbos } from "@cerbos/grpc"
import express from "express"

const cerbos = new Cerbos("localhost:3593")
const app = express()

// Authorization middleware:
function authorize(resourceKind: string, action: string) {
  return async (req: express.Request, res: express.Response, next: express.NextFunction) => {
    const decision = await cerbos.checkResource({
      principal: {
        id: req.user!.id,
        roles: req.user!.roles,
        attr: { department: req.user!.department },
      },
      resource: {
        kind: resourceKind,
        id: req.params.id || "new",
        attr: req.resourceAttrs || {},
      },
      actions: [action],
    })

    if (!decision.isAllowed(action)) {
      return res.status(403).json({ error: "Forbidden" })
    }

    next()
  }
}

// Usage:
app.get("/packages/:id", authorize("package", "read"), getPackage)
app.put("/packages/:id", authorize("package", "update"), updatePackage)
app.delete("/packages/:id", authorize("package", "delete"), deletePackage)

Permit.io

Permit.io — authorization as a service:

SDK setup

import { Permit } from "permitio"

const permit = new Permit({
  token: process.env.PERMIT_API_KEY!,
  pdp: "https://cloudpdp.api.permit.io",  // Or self-hosted PDP
})

Permission checks

import { Permit } from "permitio"

const permit = new Permit({ token: process.env.PERMIT_API_KEY! })

// Simple check:
const allowed = await permit.check("user-123", "update", "package")
console.log(allowed)  // true or false

// With resource instance:
const canEdit = await permit.check(
  "user-123",           // Who
  "update",             // Action
  {                     // Resource
    type: "package",
    key: "pkg-456",
    tenant: "org-789",
  }
)

// With context:
const canDelete = await permit.check("user-123", "delete", {
  type: "package",
  key: "pkg-456",
  attributes: {
    status: "draft",
    ownerId: "user-123",
  },
})

Express middleware

import { Permit } from "permitio"
import express from "express"

const permit = new Permit({ token: process.env.PERMIT_API_KEY! })
const app = express()

// Middleware:
function authorize(action: string, resourceType: string) {
  return async (req: express.Request, res: express.Response, next: express.NextFunction) => {
    const allowed = await permit.check(
      req.user!.id,
      action,
      {
        type: resourceType,
        key: req.params.id,
        tenant: req.user!.orgId,
      }
    )

    if (!allowed) {
      return res.status(403).json({ error: "Forbidden" })
    }

    next()
  }
}

app.get("/packages/:id", authorize("read", "package"), getPackage)
app.put("/packages/:id", authorize("update", "package"), updatePackage)

Sync users and roles

import { Permit } from "permitio"

const permit = new Permit({ token: process.env.PERMIT_API_KEY! })

// Sync user:
await permit.api.syncUser({
  key: "user-123",
  email: "royce@example.com",
  first_name: "Royce",
  attributes: { department: "engineering" },
})

// Assign role:
await permit.api.assignRole({
  user: "user-123",
  role: "admin",
  tenant: "org-789",
})

// Create resource relationship:
await permit.api.createRelationshipTuple({
  subject: "user:user-123",
  relation: "owner",
  object: "package:pkg-456",
})

OPA (Open Policy Agent)

OPA — general-purpose policy engine:

Define policies (Rego)

# policies/authz.rego
package authz

import rego.v1

default allow := false

# Anyone can read published packages:
allow if {
  input.action == "read"
  input.resource.status == "published"
}

# Authors can update their own packages:
allow if {
  input.action == "update"
  input.user.roles[_] == "author"
  input.resource.ownerId == input.user.id
}

# Admins can do anything:
allow if {
  input.user.roles[_] == "admin"
}

# Authors can delete their own drafts:
allow if {
  input.action == "delete"
  input.user.roles[_] == "author"
  input.resource.ownerId == input.user.id
  input.resource.status == "draft"
}

Query OPA from Node.js

// Query OPA server:
async function checkPermission(
  user: { id: string; roles: string[] },
  action: string,
  resource: { id: string; ownerId: string; status: string }
): Promise<boolean> {
  const response = await fetch("http://localhost:8181/v1/data/authz/allow", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({
      input: { user, action, resource },
    }),
  })

  const data = await response.json()
  return data.result === true
}

// Usage:
const allowed = await checkPermission(
  { id: "user-123", roles: ["author"] },
  "update",
  { id: "pkg-456", ownerId: "user-123", status: "published" }
)

WASM (embedded in Node.js)

import { loadPolicy } from "@open-policy-agent/opa-wasm"
import fs from "fs"

// Load compiled policy:
const policyWasm = fs.readFileSync("./policy.wasm")
const policy = await loadPolicy(policyWasm)

// Evaluate locally (no network call):
const result = policy.evaluate({
  user: { id: "user-123", roles: ["author"] },
  action: "update",
  resource: { id: "pkg-456", ownerId: "user-123", status: "published" },
})

console.log(result[0]?.result)  // { allow: true }

Express middleware

import express from "express"

const app = express()

function authorize(action: string) {
  return async (req: express.Request, res: express.Response, next: express.NextFunction) => {
    const response = await fetch("http://localhost:8181/v1/data/authz/allow", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({
        input: {
          user: { id: req.user!.id, roles: req.user!.roles },
          action,
          resource: req.resourceAttrs || {},
        },
      }),
    })

    const { result } = await response.json()

    if (!result) {
      return res.status(403).json({ error: "Forbidden" })
    }

    next()
  }
}

app.put("/packages/:id", authorize("update"), updatePackage)

Feature Comparison

FeatureCerbosPermit.ioOPA
TypeOpen-source engineSaaS platformOpen-source engine
Policy languageYAMLVisual editor + APIRego
Self-hosted✅ (PDP)
Cloud offeringCerbos Hub✅ (primary)Styra DAS
RBAC✅ (manual)
ABAC
ReBAC✅ (manual)
Audit logs
Visual editor
WASM evaluation
Multi-tenant✅ (manual)
Node.js SDK
Kubernetes✅ (sidecar)✅ (sidecar)✅ (native)
Use caseApp authorizationApp authorizationGeneral-purpose policy
Learning curveLowLowHigh (Rego)

When to Use Each

Use Cerbos if:

  • Want open-source, self-hosted authorization
  • Prefer YAML policy-as-code (version controlled)
  • Need fast local policy evaluation (sidecar pattern)
  • Building microservices that need decoupled authorization

Use Permit.io if:

  • Want managed authorization with a visual policy editor
  • Need RBAC + ABAC + ReBAC without writing policy code
  • Want audit logs and compliance features out of the box
  • Building multi-tenant SaaS applications

Use OPA if:

  • Need general-purpose policy enforcement (not just app authz)
  • Also need Kubernetes admission control or Terraform policy
  • Want WASM-compiled policies for embedded evaluation
  • Already using CNCF tooling and want ecosystem integration

Multi-Tenancy Authorization Patterns

Multi-tenant SaaS applications require authorization that enforces tenant isolation — a user in organization A must never be able to read or modify resources belonging to organization B, even if they have the same role in their respective organizations. Each authorization tool handles tenant isolation differently, and the implementation choice affects both security and performance.

Cerbos's resource policies support tenant isolation through the principal and resource attribute model. Including tenantId as both a principal attribute and a resource attribute, then adding a condition request.principal.attr.tenantId == request.resource.attr.tenantId to every resource policy, enforces that operations can only be performed within the same tenant. This condition can be extracted into a derived role or a reusable policy fragment to avoid repeating it across every resource type. Cerbos Hub's policy management features allow tenant-specific policy overrides — specific tenants can have different permission rules without modifying the global policy.

Permit.io has the most complete native multi-tenancy support, as it is designed specifically for this use case. The tenant parameter in permit.check(user, action, { type, key, tenant }) scopes permission checks to a specific tenant context. Role assignments in Permit.io are scoped per-tenant: a user can be an admin in org-A and a viewer in org-B simultaneously. Relationship-based access control (ReBAC) in Permit.io enables resource ownership models where user:alice has the owner relation to package:pkg-123 within tenant:org-A, without affecting her permissions in other tenants.

OPA's approach to multi-tenancy is fully custom — you implement tenant isolation in your Rego policies using the input data. A Rego rule that checks input.user.tenantId == input.resource.tenantId implements basic tenant isolation, and more complex policies can load tenant-specific configuration data from OPA's external data API. The flexibility is complete, but the responsibility for correctness rests entirely on the policy author. A bug in the Rego tenant isolation rule can silently allow cross-tenant access without any automated detection. Cerbos and Permit.io's structured approaches provide more guardrails for this class of security-critical logic.

Migration Guide

From ad-hoc role checks to Cerbos

Most Node.js applications start with inline role-checking code scattered through route handlers. Centralizing to Cerbos replaces conditional logic with policy evaluation:

// Before: scattered role checks in routes
app.put("/packages/:id", async (req, res) => {
  const pkg = await db.findPackage(req.params.id)
  // Permissions logic mixed with business logic:
  if (req.user.role !== "admin" && pkg.ownerId !== req.user.id) {
    return res.status(403).json({ error: "Forbidden" })
  }
  // ... update logic
})

// After: Cerbos policy evaluation
import { GRPC as Cerbos } from "@cerbos/grpc"
const cerbos = new Cerbos("localhost:3593", { tls: false })

app.put("/packages/:id", async (req, res) => {
  const pkg = await db.findPackage(req.params.id)
  const decision = await cerbos.checkResource({
    principal: { id: req.user.id, roles: [req.user.role], attributes: req.user },
    resource: { kind: "package", id: pkg.id, attributes: pkg },
    actions: ["update"],
  })
  if (!decision.isAllowed("update")) return res.status(403).json({ error: "Forbidden" })
  // ... update logic — permissions concerns removed from business logic
})

The separation of authorization logic into external policy files makes it auditable, testable with Cerbos's built-in test runner, and changeable without code deployments.

Community Adoption in 2026

OPA is the most widely deployed policy engine, particularly in infrastructure and Kubernetes contexts. It is a CNCF graduated project and is used by Google, Netflix, and hundreds of large enterprises for Kubernetes admission control, infrastructure-as-code policy, and API gateway authorization. For application-level authorization specifically, OPA's flexibility can be overkill compared to application-focused tools.

Cerbos has grown significantly since its 2021 launch, with thousands of production deployments across SaaS companies that want open-source, self-hosted authorization without vendor lock-in. Its YAML policy format, version-controllable alongside application code, appeals to engineering teams that apply "policy as code" practices. The Cerbos Hub cloud offering provides a managed control plane for distributed Cerbos deployments.

Permit.io serves the market of teams that want authorization capabilities without building and operating a policy engine. Its visual policy editor and pre-built RBAC/ABAC/ReBAC templates allow non-engineers to manage permissions rules. It is positioned as the fastest path from zero to production authorization, particularly for multi-tenant SaaS applications where tenant isolation and role management complexity is high.

Policy as Code Workflow and Team Collaboration

Authorization policy management involves both the technical implementation of policy evaluation and the organizational workflow for policy development, review, and deployment.

Cerbos's policy development workflow is file-based: authorization rules are defined in YAML or JSON files that live in your repository. This enables the same pull request workflow used for application code — policy changes go through code review, CI runs policy tests (cerbosctl test), and deployments push updated policy bundles to the Cerbos hub or self-hosted instance. The policy YAML format is readable by non-engineers (legal, compliance, product teams can review changes), which is a practical advantage for organizations where authorization rules are driven by business requirements that non-engineers must validate.

Permit.io's visual policy editor lowers the barrier to policy management for teams without dedicated authorization engineers. Business stakeholders can define role assignments, resource hierarchies, and conditional rules through a point-and-click interface without writing configuration files. The tradeoff is auditability — changes made in the UI are harder to track in git history and require discipline to export and version control policy definitions. Permit.io provides a policy-as-code export but it is a secondary workflow rather than the primary interface.

OPA's policy testing discipline is essential given Rego's expressiveness. OPA policies can express complex logic that is difficult to reason about without systematic test coverage. The opa test command runs unit tests defined in Rego itself, with assertion functions that verify policy decisions against expected outcomes. For compliance-driven applications (healthcare, finance), a policy change without corresponding test updates should fail CI — this is enforceable by requiring minimum test coverage thresholds on policy files.

Audit logging requirements differ between the three tools. Cerbos logs every authorization decision (allowed/denied, the rule that matched, the principal and resource) by default and exports these logs in structured JSON. OPA can log decisions but requires explicit configuration of the decision logger. Permit.io's cloud service captures audit logs as a managed feature. For organizations that must demonstrate authorization correctness to auditors (SOC 2, HIPAA), the out-of-the-box audit trail from Cerbos reduces compliance implementation work.

Methodology

Feature comparison based on Cerbos v0.35.x, Permit.io SDK v2.x, and OPA v0.68.x as of March 2026.

Compare security and authorization libraries on PkgPulse →

See also: AVA vs Jest and Payload CMS vs Strapi vs Directus, amqplib vs KafkaJS vs Redis Streams.

The 2026 JavaScript Stack Cheatsheet

One PDF: the best package for every category (ORMs, bundlers, auth, testing, state management). Used by 500+ devs. Free, updated monthly.