Cerbos decouples authorization from application code. The PDP evaluates requests against YAML-defined policies and returns allow/deny decisions. Policies are versioned, tested, and deployed independently from the services they protect.
Enforcing authorization at the API Gateway means:
Azure API Management (APIM) is a managed API Gateway, management plane, and developer portal. APIM applies policies at the gateway layer between the API consumer and the backend. Its send-request policy supports calling external services during request processing, which is how the Cerbos PDP is integrated.
The integration consists of three components:
The APIM policy maps each HTTP request into Cerbos's structured authorization model:
sub claim (or anonymous when no token is present), with a role of authenticated or anonymousstore:endpoint resource kind, with HTTP request metadata (method, path, path segments, host, port, scheme, query string), APIM context (service name, original URL), and optionally the request body as attributesaccess action; the Cerbos policy inspects resource attributes (method, path) to determine accessJWT verification occurs at two layers: APIM validates the token signature and expiration via the validate-jwt policy using the Azure AD OpenID configuration endpoint, and the Cerbos PDP verifies the token again via its configured JWKS endpoint when extracting claims for ABAC rules. For environments that exclusively use Microsoft Entra ID, the validate-azure-ad-token policy is a simpler alternative that accepts a tenant-id attribute directly instead of requiring an OpenID configuration URL.
The Cerbos PDP is a single stateless binary with no external dependencies. Azure Container Apps runs and scales the PDP container, exposing it to APIM over HTTPS.
The .cerbos.yaml file configures policy storage and JWT verification:
server:
httpListenAddr: ":3592"
grpcListenAddr: ":3593"
storage:
driver: disk
disk:
directory: /policies
watchForChanges: true
auxData:
jwt:
keySets:
- id: azure-ad-keys
remote:
url: https://login.microsoftonline.com/{tenant-id}/discovery/v2.0/keys
refreshInterval: 1h
engine:
defaultPolicyVersion: "1"
auxData.jwt.keySets β the PDP fetches the JWKS from the Azure AD tenant endpoint and caches it for refreshInterval. When a request includes a JWT via auxData, the PDP verifies the signature and makes decoded claims available to policies as request.auxData.jwt.watchForChanges β the PDP monitors the policy directory and reloads policies when files change, without requiring a restart.engine.defaultPolicyVersion β sets the policy version used when the request does not specify one.Upload .cerbos.yaml and policy files to Azure Files, then deploy with the following manifest:
type: Microsoft.App/containerApps
properties:
managedEnvironmentId: <cerbos-ca-env>
configuration:
activeRevisionsMode: Multiple
ingress:
allowInsecure: false
external: true
targetPort: 3592
transport: Auto
traffic:
- latestRevision: true
weight: 100
template:
containers:
- image: ghcr.io/cerbos/cerbos:latest
name: cerbos
args:
- "server"
- "--config=/config/.cerbos.yaml"
resources:
cpu: 0.25
memory: 0.5Gi
volumeMounts:
- mountPath: /config
volumeName: cerbos-config-volume
- mountPath: /policies
volumeName: cerbos-policies-volume
scale:
maxReplicas: 5
minReplicas: 2
volumes:
- name: cerbos-config-volume
storageName: cerbos-ca-config-mount
storageType: AzureFile
- name: cerbos-policies-volume
storageName: cerbos-ca-policies-mount
storageType: AzureFile
Retrieve the managedEnvironmentId:
az containerapp env show \
--resource-group $RESOURCE_GROUP \
--name $CONTAINERAPPS_ENVIRONMENT \
--query id
Create the Container App:
az containerapp create \
--name $CONTAINER_APP_NAME \
--resource-group $RESOURCE_GROUP \
--environment $CONTAINERAPPS_ENVIRONMENT \
--yaml "app.yaml"
Verify the PDP is running:
curl https://<your-cerbos-app>.azurecontainerapps.io/api/server_info
A successful response returns the Cerbos server version and build metadata.
Instead of mounting policy files via Azure Files, the PDP can pull policies from Cerbos Hub and stream decision logs back for audit. Replace the storage and audit sections in .cerbos.yaml:
hub:
credentials:
pdpID: ${CERBOS_HUB_PDP_ID}
clientID: ${CERBOS_HUB_CLIENT_ID}
clientSecret: ${CERBOS_HUB_CLIENT_SECRET}
storage:
driver: hub
hub:
remote:
deploymentID: production
audit:
enabled: true
backend: hub
hub:
storagePath: /audit_logs
The audit block streams decision logs to Cerbos Hub, where each check is recorded with its principal, resource, action, and effect. storagePath is a local buffer directory for logs awaiting upload.
This example uses a product catalog API configured in APIM. The API exposes endpoints for browsing products, viewing product details, searching, submitting reviews, and moderating reviews.
Configure the following APIM Named Values:
cerbos-authorizer-url = https://<your-cerbos-app>.azurecontainerapps.io/api/check/resources
azure-ad-openid-config-url = https://login.microsoftonline.com/{tenant-id}/v2.0/.well-known/openid-configuration
The inbound policy performs three operations:
Authorization header is present, the validate-jwt policy verifies the token signature and expiration against the Azure AD OpenID configuration, then exposes the decoded token via output-token-variable-name. The Subject property provides the principal ID. When no header is present, the principal is set to anonymous.authenticated when a Bearer token is present, anonymous otherwise.store:endpoint resource. If the PDP returns anything other than EFFECT_ALLOW, the gateway returns 403 Forbidden.<policies>
<inbound>
<base />
<set-variable name="requestId" value="@(context.RequestId)" />
<set-variable name="serviceName" value="@(context.Deployment.ServiceName)" />
<set-variable name="originalUrl" value="@(context.Request.OriginalUrl.ToString())" />
<choose>
<when condition="@((bool)context.Request.HasBody)">
<set-variable name="requestBody"
value="@(context.Request.Body.As<string>(preserveContent: true))" />
</when>
</choose>
<choose>
<when condition="@(context.Request.Headers.ContainsKey("Authorization"))">
<validate-jwt header-name="Authorization"
require-scheme="Bearer"
output-token-variable-name="jwt">
<openid-config url="{{azure-ad-openid-config-url}}" />
</validate-jwt>
<set-variable name="principalId"
value="@(((Jwt)context.Variables["jwt"]).Subject)" />
<set-variable name="principalRole" value="authenticated" />
<set-variable name="bearerToken"
value="@(context.Request.Headers.GetValueOrDefault("Authorization","").Substring(7))" />
</when>
<otherwise>
<set-variable name="principalId" value="anonymous" />
<set-variable name="principalRole" value="anonymous" />
<set-variable name="bearerToken" value="" />
</otherwise>
</choose>
<send-request mode="new" response-variable-name="cerbosResponse"
timeout="10" ignore-error="false">
<set-url>{{cerbos-authorizer-url}}</set-url>
<set-method>POST</set-method>
<set-header name="Content-Type" exists-action="override">
<value>application/json</value>
</set-header>
<set-body>@{
var segments = new Uri(context.Request.Url.ToString()).Segments;
var body = new JObject {
["requestId"] = (string)context.Variables["requestId"],
["principal"] = new JObject {
["id"] = (string)context.Variables["principalId"],
["policyVersion"] = "1",
["roles"] = new JArray((string)context.Variables["principalRole"])
},
["resources"] = new JArray(new JObject {
["resource"] = new JObject {
["kind"] = "store:endpoint",
["id"] = context.Request.Url.Path,
["policyVersion"] = "1",
["attr"] = new JObject {
["method"] = context.Request.Method,
["path"] = context.Request.Url.Path,
["path_segments"] = JArray.Parse(
JsonConvert.SerializeObject(segments)),
["host"] = context.Request.Url.Host,
["port"] = context.Request.Url.Port,
["scheme"] = context.Request.Url.Scheme,
["query_string"] = context.Request.Url.QueryString,
["service_name"] = (string)context.Variables["serviceName"],
["original_url"] = (string)context.Variables["originalUrl"]
}
},
["actions"] = new JArray("access")
})
};
if (context.Variables.ContainsKey("requestBody")) {
var attr = (JObject)body["resources"][0]["resource"]["attr"];
attr["body"] = (string)context.Variables["requestBody"];
}
var token = (string)context.Variables["bearerToken"];
if (!string.IsNullOrEmpty(token)) {
body["auxData"] = new JObject {
["jwt"] = new JObject {
["token"] = token,
["keySetId"] = "azure-ad-keys"
}
};
}
return body.ToString();
}</set-body>
</send-request>
<choose>
<when condition="@(((IResponse)context.Variables["cerbosResponse"]).StatusCode != 200)">
<return-response>
<set-status code="@(((IResponse)context.Variables["cerbosResponse"]).StatusCode)"
reason="@(((IResponse)context.Variables["cerbosResponse"]).StatusReason)" />
</return-response>
</when>
</choose>
<set-variable name="decisionJson"
value="@(((IResponse)context.Variables["cerbosResponse"]).Body.As<JObject>())" />
<choose>
<when condition="@{
var json = (JObject)context.Variables["decisionJson"];
return !json.ContainsKey("results") || !((JArray)json["results"]).HasValues;
}">
<return-response>
<set-status code="403" reason="Forbidden" />
</return-response>
</when>
</choose>
<set-variable name="accessEffect"
value="@(((JObject)context.Variables["decisionJson"])["results"][0]["actions"]["access"].ToString())" />
<choose>
<when condition="@(((string)context.Variables["accessEffect"]) != "EFFECT_ALLOW")">
<return-response>
<set-status code="403" reason="Forbidden" />
</return-response>
</when>
</choose>
</inbound>
<backend>
<base />
</backend>
<outbound>
<base />
</outbound>
<on-error>
<base />
</on-error>
</policies>
The JSON body is constructed using C#'s JObject API. This avoids escaping issues that occur with string concatenation in APIM policy expressions.
For a POST /store/products/42/reviews request with a valid JWT, the APIM policy sends:
{
"requestId": "abc123",
"principal": {
"id": "user-123",
"policyVersion": "1",
"roles": ["authenticated"]
},
"resources": [
{
"resource": {
"kind": "store:endpoint",
"id": "/store/products/42/reviews",
"policyVersion": "1",
"attr": {
"method": "POST",
"path": "/store/products/42/reviews",
"path_segments": ["/", "store/", "products/", "42/", "reviews"],
"host": "my-apim-instance.azure-api.net",
"port": 443,
"scheme": "https",
"query_string": "",
"service_name": "my-apim-instance",
"original_url": "https://my-apim-instance.azure-api.net/store/products/42/reviews",
"body": "{\"rating\":5,\"comment\":\"Great product\"}"
}
},
"actions": ["access"]
}
],
"auxData": {
"jwt": {
"token": "eyJhbG...",
"keySetId": "azure-ad-keys"
}
}
}
The PDP responds:
{
"requestId": "abc123",
"results": [
{
"resource": {
"id": "/store/products/42/reviews",
"kind": "store:endpoint"
},
"actions": {
"access": "EFFECT_ALLOW"
}
}
]
}
The same request without a Bearer token receives "access": "EFFECT_DENY" β the policy denies anonymous principals from submitting reviews.
The resource policy maps the store:endpoint resource kind to authorization rules. The APIM policy passes the HTTP method and path as resource attributes; the Cerbos policy uses CEL conditions to match on those attributes.
# policies/resource_policies/store/store_endpoint.yaml
apiVersion: api.cerbos.dev/v1
resourcePolicy:
version: "1"
resource: "store:endpoint"
rules:
- actions: ["access"]
effect: EFFECT_ALLOW
roles: ["*"]
name: allow-read
condition:
match:
expr: R.attr.method == "GET"
- actions: ["access"]
effect: EFFECT_ALLOW
roles: ["*"]
name: allow-search
condition:
match:
all:
of:
- expr: R.attr.method == "POST"
- expr: "!R.attr.path.matches(\".*/reviews$\")"
- actions: ["access"]
effect: EFFECT_ALLOW
roles: ["authenticated"]
name: allow-submit-reviews
condition:
match:
all:
of:
- expr: R.attr.method == "POST"
- expr: R.attr.path.matches(".*/reviews$")
- actions: ["access"]
effect: EFFECT_ALLOW
roles: ["authenticated"]
name: allow-moderator-delete-reviews
condition:
match:
all:
of:
- expr: R.attr.method == "DELETE"
- expr: R.attr.path.matches(".*/reviews/.*")
- expr: >-
has(request.auxData.jwt.groups)
&& request.auxData.jwt.groups.exists(g, g == "moderator")
Four rules apply to the access action:
allow-read β allows all roles to make GET requests, including anonymous.allow-search β allows all roles to POST to non-review paths (e.g., /products/search).allow-submit-reviews β restricts review submission to the authenticated role. The APIM policy assigns anonymous when no Bearer token is present and authenticated when one is.allow-moderator-delete-reviews β an ABAC rule that allows DELETE on review paths only when the JWT contains a groups claim with the value "moderator". The PDP verifies the JWT and makes decoded claims available via request.auxData.jwt. The has() guard prevents evaluation errors when the claim is absent.The first three rules are RBAC (matching on role). The fourth is ABAC: it inspects a JWT claim the PDP extracted from the bearer token. Both rule types coexist in the same resource policy.
# GET products β no auth required, returns 200
curl -H 'Ocp-Apim-Subscription-Key: <key>' \
https://<apim-instance>.azure-api.net/store/products -i
# POST review without auth β returns 403
curl -X POST -H 'Ocp-Apim-Subscription-Key: <key>' \
https://<apim-instance>.azure-api.net/store/products/42/reviews \
-d '{"rating":5,"comment":"Great product"}' -i
# POST review with valid JWT β returns 200
curl -X POST \
-H 'Ocp-Apim-Subscription-Key: <key>' \
-H 'Authorization: Bearer <JWT Token>' \
https://<apim-instance>.azure-api.net/store/products/42/reviews \
-d '{"rating":5,"comment":"Great product"}' -i
# DELETE review with moderator JWT β returns 200
curl -X DELETE \
-H 'Ocp-Apim-Subscription-Key: <key>' \
-H 'Authorization: Bearer <JWT with groups=["moderator"]>' \
https://<apim-instance>.azure-api.net/store/products/42/reviews/7 -i
# DELETE review without moderator group β returns 403
curl -X DELETE \
-H 'Ocp-Apim-Subscription-Key: <key>' \
-H 'Authorization: Bearer <JWT without moderator group>' \
https://<apim-instance>.azure-api.net/store/products/42/reviews/7 -i
Cerbos supports policy-level test definitions. The test file store_endpoint_test.yaml covers eleven scenarios β authenticated and anonymous principals across product listing, product detail, review submission, product search, and review deletion (with and without the moderator JWT group claim):
docker run --rm -v $(pwd)/policies:/policies \
ghcr.io/cerbos/cerbos:latest compile /policies
Test results
βββStoreEndpointPolicyTest [11 OK]
11 tests executed [11 OK]
Cerbos policy evaluation typically completes in under 1ms. With the PDP in the same Azure region as APIM, the send-request round trip adds low single-digit milliseconds. APIM trace diagnostics will show the full breakdown.
The PDP is stateless, so horizontal scaling needs no coordination. The manifest above sets minReplicas: 2 and maxReplicas: 5.
The three pieces: a Cerbos PDP on Azure Container Apps (policies via Azure Files or Cerbos Hub), an APIM inbound policy that translates HTTP requests into Cerbos check calls using C#/JObject expressions, and a Cerbos resource policy with CEL conditions tested via cerbos compile.
The example policy mixes RBAC (role-based read/write) and ABAC (JWT claim-based moderation) in one file. Test fixtures include auxData for JWT claims. Cerbos Hub is optional for centralized policy management and decision audit logs.
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.
What is Cerbos?
Cerbos is an end-to-end enterprise authorization software for Zero Trust environments and AI-powered systems. It enforces fine-grained, contextual, and continuous authorization across apps, APIs, AI agents, MCP servers, services, and workloads.
Cerbos consists of an open-source Policy Decision Point, Enforcement Point integrations, and a centrally managed Policy Administration Plane (Cerbos Hub) that coordinates unified policy-based authorization across your architecture. Enforce least privilege & maintain full visibility into access decisions with Cerbos authorization.