---
title: "Policy as Code with Azure API Management and Cerbos"
description: "Integrate Cerbos with Azure API Management to externalize API authorization. Deploy Cerbos PDP on Azure Container Apps, enforce RBAC and ABAC at the gateway, validate JWTs, and manage versioned policies independently from backend services for scalable, secure access control."
author: "Alex Olivier"
date: "2026-02-25T12:21:17.346Z"
canonical: "https://www.cerbos.dev/blog/policy-as-code-azure-api-management-cerbos"
image: "https://stylish-appliance-1c1cc1c30d.media.strapiapp.com/Policy_as_Code_with_Azure_API_Management_and_Cerbos_44bc88d440.png"
tags: ["guide","engineering","documentation"]
source: "https://www.cerbos.dev/blog/policy-as-code-azure-api-management-cerbos"
---

# Policy as Code with Azure API Management and Cerbos

[Cerbos](https://cerbos.dev) 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)](https://azure.microsoft.com/en-us/products/api-management) is a managed API Gateway, management plane, and developer portal. APIM applies [policies](https://learn.microsoft.com/en-us/azure/api-management/api-management-howto-policies) at the gateway layer between the API consumer and the backend. Its [`send-request`](https://learn.microsoft.com/en-us/azure/api-management/send-request-policy) policy supports calling external services during request processing, which is how the Cerbos PDP is integrated.

## Architecture

The integration consists of three components:

1. **Cerbos PDP** — the Policy Decision Point, deployed as a container reachable by the APIM gateway  
2. **APIM inbound policy** — the Policy Enforcement Point (PEP), which translates HTTP requests into Cerbos check requests and enforces the response  
3. **Cerbos resource policy** — YAML policy definitions that encode authorization rules, optionally managed via [Cerbos Hub](https://www.cerbos.dev/product-cerbos-hub)

### Authorization model

The APIM policy maps each HTTP request into Cerbos's structured authorization model:

- **Principal** — extracted from the JWT `sub` claim (or `anonymous` when no token is present), with a role of `authenticated` or `anonymous`  
- **Resource** — the `store: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 attributes  
- **Action** — a single `access` action; 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`](https://learn.microsoft.com/en-us/azure/api-management/validate-jwt-policy) 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`](https://learn.microsoft.com/en-us/azure/api-management/validate-azure-ad-token-policy) 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](https://learn.microsoft.com/en-us/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 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`.  
- `insecure.optionalAlg: true` — required for Microsoft Entra ID. Its JWKS endpoint omits the optional `alg` field 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](https://docs.cerbos.dev/cerbos/latest/configuration/auxdata.html) 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`:

```shell
az containerapp env show \
  --resource-group $RESOURCE_GROUP \
  --name $CONTAINERAPPS_ENVIRONMENT \
  --query id
```

Create the Container App:

```shell
az containerapp create \
  --name $CONTAINER_APP_NAME \
  --resource-group $RESOURCE_GROUP \
  --environment $CONTAINERAPPS_ENVIRONMENT \
  --yaml "app.yaml"
```

Verify the PDP is running:

```shell
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](https://www.cerbos.dev/product-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:

1. **Validate and extract principal identity** — when an `Authorization` header is present, the [`validate-jwt`](https://learn.microsoft.com/en-us/azure/api-management/validate-jwt-policy) 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`.  
2. **Determine principal role** — assign `authenticated` when a Bearer token is present, `anonymous` otherwise.  
3. **Call Cerbos and enforce** — send a check request to the PDP with the HTTP request mapped to a `store:endpoint` resource. If the PDP returns anything other than `EFFECT_ALLOW`, the gateway returns `403 Forbidden`.

```xml
<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.

### Request and response format

For a `POST /store/products/42/reviews` request with a valid JWT, the APIM policy sends:

```json
{
  "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:

```json
{
  "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.

## Step 3: Define Cerbos policies

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.

## Testing

### End-to-end via APIM

```shell
# 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
```

### Policy unit tests

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):

```shell
docker run --rm -v $(pwd)/policies:/policies \
  ghcr.io/cerbos/cerbos:latest compile /policies
```

```
Test results
└──StoreEndpointPolicyTest [11 OK]
11 tests executed [11 OK]
```

## Performance

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`.

## Summary

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](https://www.cerbos.dev/product-cerbos-hub) is optional for centralized policy [management](https://www.cerbos.dev/features-benefits-and-use-cases/centralized-management) and decision [audit logs](https://www.cerbos.dev/features-benefits-and-use-cases/audit-logs).
