MCP Authorization with Cerbos: Policy-as-Code for AI Agents

Photo by Unsplash

Photo by Unsplash
The Model Context Protocol (MCP) by Anthropic lets AI agents call tools — read files, query databases, send emails, delete records. That's powerful. It's also a security problem waiting to happen if every user who can chat with your agent can also call delete_record or send_invoice. Most MCP implementations I've seen either have no authorization at all or hard-code role checks inside each tool handler. Both approaches break down at scale. Cerbos is the clean solution: a policy-as-code authorization engine that sits as a sidecar and evaluates YAML policies for every tool call.
When you build an MCP server for an internal tool — say, an ERP assistant that can query purchase orders, approve invoices, and send notifications — you don't want every employee to have access to every capability. A warehouse clerk should be able to read inventory records but not approve payroll. A manager should approve invoices but not delete them. Without authorization middleware, your MCP server is essentially a privilege escalation vector: an employee asks the AI agent to do something above their permission level, and it just... does it.
Cerbos (cerbos.dev) is an open-source, language-agnostic authorization engine. You write policies in YAML that define which roles can perform which actions on which resource types. Cerbos runs as a sidecar container (or standalone service) and exposes a REST and gRPC API. Your application calls Cerbos with a principal (user + roles + attributes) and a resource (what they want to access) and gets back an allow/deny decision. Cerbos never touches your database or your users — it only evaluates policies.
MCP tools are identified by name — tools like 'read_orders', 'approve_invoice', 'delete_record', 'send_email'. Your MCP server receives tool call requests from a client. Without authorization middleware, the server executes every tool call from any authenticated session. The fix is a middleware layer that intercepts each tool call, extracts the caller's identity and role from the JWT or API key, and asks Cerbos: 'Is this principal allowed to call this tool?' before execution.
MCP Authorization Flow with Cerbos
User / Agent Client
│
│ MCP tool call request
│ { tool: "delete_record", principal: { id, role } }
▼
┌───────────────────────────────────┐
│ MCP Server │
│ ┌────────────────────────────┐ │
│ │ Auth Middleware │ │
│ │ 1. Extract JWT / API key │ │
│ │ 2. Build principal obj │ │
│ └────────────┬───────────────┘ │
└───────────────┼───────────────────┘
│ CheckResourceRequest
▼
┌───────────────────────────────────┐
│ Cerbos PDP (sidecar) │
│ ┌────────────────────────────┐ │
│ │ Policy Engine │ │
│ │ resource: mcp_tool │ │
│ │ action: call │ │
│ │ principal.role → ALLOW? │ │
│ └────────────┬───────────────┘ │
└───────────────┼───────────────────┘
│ { allowed: true | false }
▼
┌───────────────┐
│ ALLOW │──▶ Tool Execution → Response
│ DENY │──▶ 403 Forbidden
└───────────────┘Run Cerbos as a Docker sidecar with --set=server.logLevel=info and mount your policies directory as a volume. This way, updating a policy YAML file triggers a hot reload in Cerbos without restarting your MCP server. In production, use Cerbos Hub (their managed service) for policy storage with audit logging and GitOps-style policy deployment.
Getting Cerbos running locally takes about five minutes. Start the sidecar, write a resource policy for your MCP tools, and call the Cerbos REST or gRPC API from your MCP server middleware. The policy YAML defines resources (mcp_tool), actions (call), roles (admin, pro, free), and conditions (which specific tool names are allowed for each role). The code example above shows the complete policy file and the TypeScript middleware.
For SaaS products where each customer organization has its own users and roles, Cerbos supports policy scoping by tenant. You add a scope field to your principal that identifies the organization, and write scoped policies that only apply to that tenant. This lets one Cerbos instance serve all your tenants without policy conflicts. Combine this with JWT claims that embed the user's organization ID and roles, and you have a complete multi-tenant authorization system for MCP agents.
# cerbos/policies/mcp_tool.yaml
apiVersion: api.cerbos.dev/v1
resourcePolicy:
version: "default"
resource: mcp_tool
rules:
# Admin can call any tool
- actions: ["*"]
effect: EFFECT_ALLOW
roles: ["admin"]
# Pro users can call data tools but not destructive ones
- actions: ["call"]
effect: EFFECT_ALLOW
roles: ["pro"]
condition:
match:
expr: >
resource.attr.toolName in [
"read_records", "search_records",
"create_record", "update_record",
"generate_report", "export_csv"
]
# Free users: read-only tools only
- actions: ["call"]
effect: EFFECT_ALLOW
roles: ["free"]
condition:
match:
expr: >
resource.attr.toolName in ["read_records", "search_records"]
---
# MCP Server middleware — TypeScript
import { GRPC as Cerbos } from "@cerbos/grpc"
const cerbos = new Cerbos("localhost:3593", { tls: false })
export async function checkToolPermission(
principal: { id: string; role: string; attributes?: Record<string, unknown> },
toolName: string
): Promise<boolean> {
const decision = await cerbos.checkResource({
principal: {
id: principal.id,
roles: [principal.role],
attributes: principal.attributes ?? {},
},
resource: {
kind: "mcp_tool",
id: toolName,
attributes: { toolName },
},
actions: ["call"],
})
return decision.isAllowed("call")
}
// Usage in MCP tool handler
server.tool("delete_record", async (args, context) => {
const allowed = await checkToolPermission(
{ id: context.user.id, role: context.user.role },
"delete_record"
)
if (!allowed) {
throw new McpError(
ErrorCode.MethodNotFound,
"You do not have permission to call delete_record"
)
}
// proceed with deletion...
return await deleteRecord(args.id)
})The power of Cerbos policies is that they're declarative and auditable. A policy file tells a non-developer exactly who can do what. You can add conditions using the Common Expression Language (CEL) — for example, only allow delete actions on records created by the principal, or only allow read on records where status != 'confidential'. These conditions reference resource attributes that you pass in the CheckResource request.
A common shortcut is skipping authorization on read-only MCP tools because 'reads can't cause damage.' But read access to the wrong data is a data breach. An HR records tool should not be readable by everyone — only HR staff. A financial reporting tool should not expose raw transaction data to non-finance roles. Apply Cerbos policies to every MCP tool, not just the destructive ones.
Cerbos has a built-in policy testing framework — you write test YAML files alongside your policy files that define test fixtures with principals, resources, and expected outcomes (allow/deny). Run 'cerbos compile .' to validate policies and 'cerbos test .' to run all policy tests. This means you can write authorization tests the same way you write unit tests, and catch policy regressions before they reach production.
In production, deploy Cerbos as a sidecar on the same pod as your MCP server (Kubernetes) or the same VM (non-containerized). Keep the communication local (localhost:3593) to minimize latency — Cerbos decisions are typically sub-millisecond on the same machine. Use Cerbos Hub for policy management if you want audit trails, GitOps deployment, and a web UI for policy editing. Set up alerts on Cerbos's /metrics endpoint for decision latency and error rate. A failing Cerbos instance should fail-closed (deny all) not fail-open.