Policy as Code With Azure API Management and Cerbos | Cerbos
Policy as Code with Azure API Management and Cerbos
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:
- Authorization logic is defined once, not reimplemented per service
- Policy changes deploy without touching backend code
- Denied requests never reach backend infrastructure
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.
Architecture
The integration consists of three components:
- Cerbos PDP — the Policy Decision Point, deployed as a container reachable by the APIM gateway
- APIM inbound policy — the Policy Enforcement Point (PEP), which translates HTTP requests into Cerbos check requests and enforces the response
- Cerbos resource policy — YAML policy definitions that encode authorization rules, optionally managed via Cerbos Hub
Authorization model
The APIM policy maps each HTTP request into Cerbos's structured authorization model:
- Principal — extracted from the JWT
subclaim (oranonymouswhen no token is present), with a role ofauthenticatedoranonymous - Resource — the
store:endpointresource 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 attributes - Action — a single
accessaction; the Cerbos policy inspects resource attributes (method, path) to determine access
JWT 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.
Step 1: Host Cerbos on Azure Container Apps
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.
Cerbos configuration
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
insecure:
optionalAlg: true
engine:
defaultPolicyVersion: "1"
auxData.jwt.keySets— the PDP fetches the JWKS from the Azure AD tenant endpoint and caches it forrefreshInterval. When a request includes a JWT viaauxData, the PDP verifies the signature and makes decoded claims available to policies asrequest.auxData.jwt.insecure.optionalAlg: true— required for Microsoft Entra ID. Its JWKS endpoint omits the optionalalgfield on each key, and Cerbos rejects such keys by default to guard against algorithm-confusion attacks. With this flag enabled, the algorithm is inferred from the key type (RSA→ RS256), and Cerbos logs an[INSECURE CONFIG]warning on startup to make the trade-off explicit. See the auxData configuration docs for details.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.
Container Apps deployment
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.
Alternative: Cerbos Hub
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.
Step 2: Configure Azure APIM
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.
Named values
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
APIM inbound policy
The inbound policy performs three operations:
- Validate and extract principal identity — when an
Authorizationheader is present, thevalidate-jwtpolicy verifies the token signature and expiration against the Azure AD OpenID configuration, then exposes the decoded token viaoutput-token-variable-name. TheSubjectproperty provides the principal ID. When no header is present, the principal is set toanonymous. - Determine principal role — assign
authenticatedwhen a Bearer token is present,anonymousotherwise. - Call Cerbos and enforce — send a check request to the PDP with the HTTP request mapped to a
store:endpointresource. If the PDP returns anything other thanEFFECT_ALLOW, the gateway returns403 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"])[