Home / Azure / Azure API Management: Building a Scalable API Gateway
Azure

Azure API Management: Building a Scalable API Gateway

Design enterprise API gateways with Azure API Management — policies, versioning, rate limiting, JWT validation, caching, and developer portal configuration.

What you will learn

Practical execution with concise explanations, real implementation patterns, and production-ready recommendations.

Azure API Management: Building a Scalable API Gateway

Azure API Management (APIM) is Microsoft's fully managed service for publishing, securing, transforming, and monitoring APIs at enterprise scale. It acts as the critical gateway between your backend services and consumers — mobile apps, partner integrations, developer portals, and microservices.


APIM Architecture

APIM Architecture

flowchart LR
    subgraph Consumers["API Consumers"]
        MOB[Mobile Apps]
        WEB[Web Clients]
        PARTNER[Partner\nIntegrations]
        INTERNAL[Internal\nMicroservices]
    end

    subgraph APIM["Azure API Management"]
        GW[API Gateway\nPolicies / Routing]
        PORTAL[Developer Portal\nDocs / Testing]
        MGMT[Management API\nProvisioning]

        subgraph Policies["Policy Engine (inbound → backend → outbound)"]
            AUTH[JWT Validation\nOAuth 2.0 / API Key]
            RATE[Rate Limiting\nQuota Enforcement]
            CACHE[Response Caching]
            TRANSFORM[Request/Response\nTransformation]
        end
    end

    subgraph Backends["Backend Services"]
        FUNC[Azure Functions]
        ACA[Container Apps]
        APPSVC[App Service]
        LEGACY[Legacy / On-Prem\nvia VPN]
    end

    Consumers --> GW
    GW --> Policies
    Policies --> Backends
    GW --> PORTAL

    style Consumers fill:#dbeafe,stroke:#3b82f6,color:#1e3a8a
    style APIM fill:#ede9fe,stroke:#8b5cf6,color:#4c1d95
    style Backends fill:#d1fae5,stroke:#059669,color:#065f46

Tier Comparison

Tier Use Case SLA VNet Support Developer Portal Price
Consumption Serverless / low volume 99.95% Per call
Developer Dev / testing only None ✅ (injection) Low fixed
Basic Small prod workloads 99.95% Medium fixed
Standard Medium enterprise 99.95% Higher fixed
Premium Multi-region, enterprise 99.99% ✅ (full) Highest

Step 1: Create an APIM Instance

Step 1: Create an APIM Instance

az group create --name rg-apim-demo --location eastus

# Developer tier for testing (takes 30–40 min to provision)
az apim create \
  --resource-group rg-apim-demo \
  --name mycompany-apim \
  --publisher-name "My Company" \
  --publisher-email admin@mycompany.com \
  --sku-name Developer \
  --location eastus

# Check provisioning state
az apim show \
  --resource-group rg-apim-demo \
  --name mycompany-apim \
  --query "provisioningState"

Step 2: Import an API

# Import from OpenAPI spec (Swagger URL)
az apim api import \
  --resource-group rg-apim-demo \
  --service-name mycompany-apim \
  --api-id orders-api \
  --path /orders \
  --specification-format OpenApi \
  --specification-url "https://petstore.swagger.io/v2/swagger.json" \
  --display-name "Orders API" \
  --api-type http

Step 3: Policy Pipeline

Step 3: Policy Pipeline

sequenceDiagram
    participant C as API Consumer
    participant GW as APIM Gateway
    participant BE as Backend Service

    C->>GW: Request + Authorization header
    Note over GW: INBOUND POLICIES
    GW->>GW: Validate JWT (Azure AD)
    GW->>GW: Check rate limit (100/min per subscription)
    GW->>GW: Check cache — hit?
    alt Cache hit
        GW-->>C: 200 Cached response
    else Cache miss
        GW->>BE: Forwarded request (transformed)
        Note over GW: BACKEND POLICIES
        BE-->>GW: Response
        Note over GW: OUTBOUND POLICIES
        GW->>GW: Store in cache (5 min)
        GW->>GW: Remove internal headers
        GW-->>C: 200 Response
    end

Full Inbound + Outbound Policy

<policies>
  <inbound>
    <base />

    <!-- 1. Validate Azure AD JWT -->
    <validate-jwt header-name="Authorization" failed-validation-httpcode="401">
      <openid-config url="https://login.microsoftonline.com/{tenant-id}/v2.0/.well-known/openid-configuration" />
      <required-claims>
        <claim name="aud" match="all">
          <value>{api-client-id}</value>
        </claim>
      </required-claims>
    </validate-jwt>

    <!-- 2. Rate limit: 100 calls per minute per subscription -->
    <rate-limit-by-key
      calls="100"
      renewal-period="60"
      counter-key="@(context.Subscription.Id)" />

    <!-- 3. CORS for web clients -->
    <cors allow-credentials="true">
      <allowed-origins>
        <origin>https://myapp.azurewebsites.net</origin>
      </allowed-origins>
      <allowed-methods>
        <method>GET</method>
        <method>POST</method>
        <method>PUT</method>
        <method>DELETE</method>
      </allowed-methods>
      <allowed-headers>
        <header>Authorization</header>
        <header>Content-Type</header>
      </allowed-headers>
    </cors>

    <!-- 4. Check response cache -->
    <cache-lookup vary-by-developer="false" vary-by-developer-groups="false">
      <vary-by-query-parameter>page</vary-by-query-parameter>
      <vary-by-query-parameter>pageSize</vary-by-query-parameter>
    </cache-lookup>
  </inbound>

  <backend>
    <base />
    <!-- Retry on 5xx with exponential backoff -->
    <retry condition="@(context.Response.StatusCode >= 500)" count="3" interval="2" max-interval="10" delta="2">
      <forward-request />
    </retry>
  </backend>

  <outbound>
    <base />
    <!-- Cache successful responses for 5 minutes -->
    <cache-store duration="300" />
    <!-- Strip internal headers before sending to consumer -->
    <set-header name="X-Powered-By" exists-action="delete" />
    <set-header name="X-AspNet-Version" exists-action="delete" />
  </outbound>

  <on-error>
    <base />
    <!-- Standardise error response format -->
    <set-body>@{
      return new JObject(
        new JProperty("error", context.LastError.Message),
        new JProperty("requestId", context.RequestId)
      ).ToString();
    }</set-body>
  </on-error>
</policies>

Step 4: API Versioning

flowchart TD
    CONSUMER[API Consumer] --> APIM[APIM Gateway]
    APIM --> V1["/v1/orders\n(legacy — maintained)"]
    APIM --> V2["/v2/orders\n(current — new features)"]
    APIM --> V3["/v3/orders\n(preview — limited access)"]

    V1 --> BE1[Orders Service v1\nAzure Functions]
    V2 --> BE2[Orders Service v2\nContainer Apps]
    V3 --> BE3[Orders Service v3\nContainer Apps Preview]

    style V2 fill:#d1fae5,stroke:#059669,color:#065f46
    style V3 fill:#fef3c7,stroke:#f59e0b,color:#78350f
    style V1 fill:#fee2e2,stroke:#ef4444,color:#7f1d1d
# Create versioned API set
az apim api versionset create \
  --resource-group rg-apim-demo \
  --service-name mycompany-apim \
  --display-name "Orders API Versions" \
  --scheme Segment \
  --version-header-name "api-version"

# Create v2 of the API
az apim api create \
  --resource-group rg-apim-demo \
  --service-name mycompany-apim \
  --api-id orders-api-v2 \
  --path /orders \
  --display-name "Orders API v2" \
  --api-version v2

Step 5: Named Values (Secrets Management)

Store reusable configuration as Named Values — never hardcode backend URLs or API keys in policies:

# Store a backend URL as a named value
az apim nv create \
  --resource-group rg-apim-demo \
  --service-name mycompany-apim \
  --named-value-id orders-backend-url \
  --display-name "Orders Backend URL" \
  --value "https://orders-api-prod.azurewebsites.net" \
  --secret false

# Reference in policy: {{orders-backend-url}}
<!-- Reference named value in backend policy -->
<set-backend-service base-url="{{orders-backend-url}}" />

For secrets (API keys, tokens):

# Store as a Key Vault reference
az apim nv create \
  --resource-group rg-apim-demo \
  --service-name mycompany-apim \
  --named-value-id backend-api-key \
  --display-name "Backend API Key" \
  --key-vault-secret-identifier "https://myvault.vault.azure.net/secrets/BackendApiKey" \
  --secret true

Step 6: Monitoring

# Enable Application Insights integration
az apim logger create \
  --resource-group rg-apim-demo \
  --service-name mycompany-apim \
  --logger-id apim-ai-logger \
  --logger-type applicationInsights \
  --connection-string "InstrumentationKey=xxx;IngestionEndpoint=https://eastus-0.in.applicationinsights.azure.com/"

# Diagnostic settings on the API
az apim api diagnostic create \
  --resource-group rg-apim-demo \
  --service-name mycompany-apim \
  --api-id orders-api \
  --logger-id apim-ai-logger \
  --sampling-percentage 100

Key KQL queries for APIM:

// Request volume and error rate by API operation
ApiManagementGatewayLogs
| where TimeGenerated > ago(1h)
| summarize
    Total = count(),
    Errors = countif(ResponseCode >= 400),
    AvgDuration = avg(TotalTime)
  by OperationId
| extend ErrorRate = Errors * 100.0 / Total
| order by Total desc
// Top consumers by request volume
ApiManagementGatewayLogs
| where TimeGenerated > ago(24h)
| summarize RequestCount = count() by SubscriptionId
| order by RequestCount desc
| take 10

Best Practices

Use Named Values for all configuration. Never hardcode backend URLs, API keys, or environment-specific values in policy XML — Named Values make policies environment-independent.

Version from day one. It is far harder to add versioning to an existing API than to build it in from the start. Use URL path versioning (/v1/, /v2/) for simplicity.

Apply rate limiting per subscription, not per IP. IP-based limiting breaks behind NAT. Subscription-based limiting is fair and auditable.

Use retry policies for resilience. Wrap backend calls in <retry> with exponential backoff — transient errors are common in distributed systems.

Enable caching for read-heavy APIs. Even a 60-second cache on list endpoints dramatically reduces backend load at peak traffic.


Troubleshooting

Symptom Cause Fix
401 Unauthorized JWT validation failing Check openid-config URL and aud claim value
429 Too Many Requests Rate limit hit Review subscription quota or increase limit
Backend returns 502 Backend unreachable or timeout Check backend URL (Named Value); increase timeout in <forward-request>
CORS errors in browser Missing CORS policy Add <cors> policy to the API or product scope
Cache not working Vary-by headers mismatch Ensure vary-by-query-parameter matches actual parameters
Policy not applying Wrong policy scope Check API / operation / product scope hierarchy

Key Takeaways

  • ✅ APIM decouples consumers from backends — backend changes never break consumer contracts
  • ✅ Policy XML handles auth, rate limiting, caching, and transformation at the gateway layer
  • ✅ Named Values keep policies clean and environment-agnostic
  • ✅ Version from day one — URL path versioning is the most universally compatible approach
  • ✅ Application Insights integration gives you full visibility into API usage and latency

Additional Resources


What policies have been most valuable in your APIM implementation? Share your patterns below.

Discussion