Azure APIM MCP Production Guide

Securing Azure APIM MCP Servers in Production

In Part 1, I covered the good, bad, and ugly of Azure APIM MCP. Now let's talk about the security gaps you need to address before going to production.

Here's the reality: As a preview feature, Azure APIM MCP has permissive defaults that work great for prototyping but need hardening for production. You'll need to add security policies manually—Microsoft is actively working on better defaults, but let's not wait. Here's how to lock it down today.

Let's break down what's exposed by default and the patterns to secure it.


The /tools/list Authentication Issue

The Problem

Let's start with the most obvious one. The /tools/list endpoint—used for tool discovery—doesn't enforce subscription key validation by default.

sequenceDiagram participant Attacker participant APIM participant Backend Note over Attacker,Backend: WITHOUT FIX (Default Behavior) Attacker->>APIM: tools/list (no auth header) APIM-->>Attacker: Full tool list Note over Attacker,Backend: WITH FIX (Manual Policy) Attacker->>APIM: tools/list (no auth header) APIM->>APIM: Validate subscription key APIM-->>Attacker: 401 Unauthorized

Anyone can enumerate your available tools without credentials:

# No authentication required
curl -X POST https://your-apim.azure-api.net/your-api-mcp/mcp \
  -H "Content-Type: application/json" \
  -d '{"jsonrpc": "2.0", "method": "tools/list", "id": 1}'

# Returns full list of exposed tools
{
  "jsonrpc": "2.0",
  "id": 1,
  "result": {
    "tools": [
      {"name": "getCustomer", "description": "..."},
      {"name": "updateProfile", "description": "..."},
      {"name": "processPayment", "description": "..."}
    ]
  }
}

This is information disclosure—tool names and descriptions can reveal business logic, which you'll want to lock down for production environments.

The Fix: Explicit Subscription Key Validation

Add subscription key validation to your MCP server policy:

<policies>
    <inbound>
        <!-- Step 1: Check if subscription key is present -->
        <choose>
            <when condition="@(context.Request.Headers.GetValueOrDefault(\"Ocp-Apim-Subscription-Key\", \"\") == \"\")">
                <return-response>
                    <set-status code="401" reason="Unauthorized" />
                    <set-header name="Content-Type" exists-action="override">
                        <value>application/json</value>
                    </set-header>
                    <set-body>{"error": "Subscription key required"}</set-body>
                </return-response>
            </when>
        </choose>
        
        <!-- Step 2: Store the subscription key (APIM strips it during processing) -->
        <set-variable name="originalSubKey" 
                      value="@(context.Request.Headers.GetValueOrDefault(\"Ocp-Apim-Subscription-Key\", \"\"))" />
        
        <base />
        
        <!-- Step 3: Validate subscription after base processing -->
        <choose>
            <when condition="@(context.Subscription == null || context.Subscription.Id == null)">
                <return-response>
                    <set-status code="401" reason="Unauthorized" />
                    <set-header name="Content-Type" exists-action="override">
                        <value>application/json</value>
                    </set-header>
                    <set-body>{"error": "Invalid subscription key"}</set-body>
                </return-response>
            </when>
        </choose>
        
        <!-- Step 4: Re-inject subscription key for backend APIs -->
        <set-header name="Ocp-Apim-Subscription-Key" exists-action="override">
            <value>@((string)context.Variables[\"originalSubKey\"])</value>
        </set-header>
    </inbound>
    <backend>
        <base />
    </backend>
    <outbound>
        <base />
    </outbound>
    <on-error>
        <base />
    </on-error>
</policies>

Why Re-injection is Necessary

Here's the catch: APIM base policies might strip the Ocp-Apim-Subscription-Key header during policy processing, depending on your base policy configuration. This prevents inbound sensitive data from leaking to downstream services. But your backend APIs need that key for their own validation.

This is different from standard APIM patterns. Normally, APIM validates the request, strips sensitive headers, and calls the backend. Done. With MCP tool calls, the flow is different:

flowchart LR A[MCP Client] --> B[APIM MCP Server validates] B --> C[APIM validates] C --> D[Backend]

Is that double validation? Yes. It's a tradeoff when you expose your APIs as MCP tools—but it's efficient and easily solvable with the re-injection pattern.

The re-injection pattern:

  1. Store the key in a variable before <base />
  2. Let APIM process the request
  3. Re-inject the key after <base /> so it reaches your backend

Subscription-Based Access Control

Product Authorization Limitation

Normally, APIM's standard access control uses Products—logical groupings of APIs with different access levels (think: Starter, Professional, Enterprise plans for your customers). You check context.Product in policies.

This isn't available for MCP servers yet. context.Product returns null. Microsoft is aware of this limitation, but for now we'll need an alternative approach.

Alternative: Subscription Whitelisting

Use subscription IDs for access control:

<policies>
    <inbound>
        <!-- Basic authentication (from previous example) -->
        <choose>
            <when condition="@(context.Request.Headers.GetValueOrDefault(\"Ocp-Apim-Subscription-Key\", \"\") == \"\")">
                <return-response>
                    <set-status code="401" reason="Unauthorized" />
                    <set-body>{"error": "Subscription key required"}</set-body>
                </return-response>
            </when>
        </choose>
        
        <set-variable name="originalSubKey" 
                      value="@(context.Request.Headers.GetValueOrDefault(\"Ocp-Apim-Subscription-Key\", \"\"))" />
        <base />
        
        <!-- Validate subscription exists -->
        <choose>
            <when condition="@(context.Subscription == null || context.Subscription.Id == null)">
                <return-response>
                    <set-status code="401" reason="Unauthorized" />
                    <set-body>{"error": "Invalid subscription key"}</set-body>
                </return-response>
            </when>
        </choose>
        
        <!-- Subscription whitelist for MCP access -->
        <choose>
            <when condition="@(!new string[] {
                \"sub-id-premium-1\", 
                \"sub-id-premium-2\", 
                \"sub-id-enterprise-1\"
            }.Contains(context.Subscription.Id))">
                <return-response>
                    <set-status code="403" reason="Forbidden" />
                    <set-header name="Content-Type" exists-action="override">
                        <value>application/json</value>
                    </set-header>
                    <set-body>{"error": "Access denied - MCP access not authorized for this subscription"}</set-body>
                </return-response>
            </when>
        </choose>
        
        <!-- Re-inject subscription key -->
        <set-header name="Ocp-Apim-Subscription-Key" exists-action="override">
            <value>@((string)context.Variables[\"originalSubKey\"])</value>
        </set-header>
    </inbound>
    <backend>
        <base />
    </backend>
    <outbound>
        <base />
    </outbound>
    <on-error>
        <base />
    </on-error>
</policies>

Alternative: Subscription Name Pattern

If you don't want to maintain ID lists, use naming conventions:

<when condition="@(!context.Subscription.Name.Contains(\"MCP-Enabled\"))">
    <return-response>
        <set-status code="403" reason="Forbidden" />
        <set-body>{"error": "MCP access not authorized"}</set-body>
    </return-response>
</when>

Then name your subscriptions: Company-ABC-MCP-Enabled, Team-XYZ-MCP-Enabled, etc.


Rate Limiting

This one is critical. Rate limiting for MCP servers is essential—you're exposing APIs to AI agents that can (and will) spam requests if they're having a bad day, giving you a horrible day.

Global Rate Limiting

<policies>
    <inbound>
        <!-- Authentication policies first -->
        <!-- ... -->
        
        <!-- Rate limiting AFTER authentication -->
        <rate-limit calls="100" renewal-period="60" />
        
        <!-- Rest of inbound policies -->
    </inbound>
    <outbound>
        <!-- Add rate limit headers to responses -->
        <set-header name="X-RateLimit-Limit" exists-action="override">
            <value>100</value>
        </set-header>
        <set-header name="X-RateLimit-Window" exists-action="override">
            <value>60</value>
        </set-header>
        <base />
    </outbound>
</policies>

Important: Place rate limiting after authentication. Otherwise, attackers can exhaust your rate limits without valid credentials. Don't make it that easy for them.

Rate Limiting Behavior

When limits are exceeded:

  • APIM returns HTTP 429 (Too Many Requests)
  • Includes Retry-After header
  • Counters reset after renewal period

Test it:

# Rapid-fire requests
for i in {1..150}; do
  curl -X POST https://your-apim.azure-api.net/your-api-mcp/mcp \
    -H "Ocp-Apim-Subscription-Key: YOUR_KEY" \
    -d '{"jsonrpc": "2.0", "method": "tools/list", "id": '$i'}' \
    -w "Request $i: %{http_code}\n" \
    -s -o /dev/null
done

# First 100: HTTP 200
# Requests 101+: HTTP 429

User Context Propagation (JWT Tokens)

When you need user context: For authenticated operations where users interact through AI agents, you need to propagate user identity.

The Challenge

Consider the following flow:

flowchart LR A[User Browser] -->|JWT| B[AI Agent] B -->|JWT| C[MCP Server] C -->|JWT| D[Backend API]

Each hop must preserve and validate the JWT token. Let's make sure that happens.

Assumption: Your APIM base policy strips Authorization headers (which is good security practice—limits unwanted token exposure downstream). That's why we need the same re-injection pattern.

JWT Forwarding Policy

<policies>
    <inbound>
        <!-- Subscription key validation -->
        <choose>
            <when condition="@(context.Request.Headers.GetValueOrDefault(\"Ocp-Apim-Subscription-Key\", \"\") == \"\")">
                <return-response>
                    <set-status code="401" reason="Unauthorized" />
                    <set-body>{"error": "Subscription key required"}</set-body>
                </return-response>
            </when>
        </choose>
        
        <!-- Store BOTH subscription key AND JWT token -->
        <set-variable name="originalSubKey" 
                      value="@(context.Request.Headers.GetValueOrDefault(\"Ocp-Apim-Subscription-Key\", \"\"))" />
        <set-variable name="originalJwtToken" 
                      value="@(context.Request.Headers.GetValueOrDefault(\"Authorization\", \"\"))" />
        
        <base />
        
        <!-- Subscription validation -->
        <choose>
            <when condition="@(context.Subscription == null || context.Subscription.Id == null)">
                <return-response>
                    <set-status code="401" reason="Unauthorized" />
                    <set-body>{"error": "Invalid subscription key"}</set-body>
                </return-response>
            </when>
        </choose>
        
        <!-- Re-inject both headers -->
        <set-header name="Ocp-Apim-Subscription-Key" exists-action="override">
            <value>@((string)context.Variables[\"originalSubKey\"])</value>
        </set-header>
        <set-header name="Authorization" exists-action="override">
            <value>@((string)context.Variables[\"originalJwtToken\"])</value>
        </set-header>
    </inbound>
    <backend>
        <base />
    </backend>
    <outbound>
        <base />
    </outbound>
    <on-error>
        <base />
    </on-error>
</policies>

Optional: JWT Validation in APIM

Why validate in APIM? The forwarding pattern passes tokens through without checking them. If you want APIM to verify tokens are valid before they reach your backend, add JWT validation.

This adds an extra security layer—invalid or expired tokens get rejected at the gateway, not at your backend.

<validate-jwt header-name="Authorization" 
              failed-validation-httpcode="401" 
              failed-validation-error-message="Unauthorized. Valid JWT required.">
    <openid-config url="https://your-auth-server/.well-known/openid-configuration" />
    <audiences>
        <audience>https://your-apim.azure-api.net</audience>
    </audiences>
    <required-claims>
        <claim name="scope" match="any">
            <value>mcp:read</value>
            <value>mcp:execute</value>
        </claim>
    </required-claims>
</validate-jwt>

Key configuration points:

  • openid-config: Points to your identity provider's discovery endpoint (Azure AD, Auth0, Okta, etc.)
  • audiences: Must match the aud claim in your JWT—typically your APIM gateway URL or API identifier
  • required-claims: Enforces specific scopes—adjust these to match your authorization model

Complete Production-Ready Security Policy

Putting it all together: Here's the complete security policy combining all the patterns above:

<policies>
    <inbound>
        <!-- CORS (if needed for browser clients) -->
        <cors allow-credentials="false">
            <allowed-origins>
                <origin>https://your-app.com</origin>
            </allowed-origins>
            <allowed-methods>
                <method>POST</method>
                <method>OPTIONS</method>
            </allowed-methods>
            <allowed-headers>
                <header>Content-Type</header>
                <header>Ocp-Apim-Subscription-Key</header>
                <header>Authorization</header>
            </allowed-headers>
        </cors>
        
        <!-- 1. Subscription key validation -->
        <choose>
            <when condition="@(context.Request.Headers.GetValueOrDefault(\"Ocp-Apim-Subscription-Key\", \"\") == \"\")">
                <return-response>
                    <set-status code="401" reason="Unauthorized" />
                    <set-header name="Content-Type" exists-action="override">
                        <value>application/json</value>
                    </set-header>
                    <set-body>{"error": "Subscription key required"}</set-body>
                </return-response>
            </when>
        </choose>
        
        <!-- 2. Store headers before base processing -->
        <set-variable name="originalSubKey" 
                      value="@(context.Request.Headers.GetValueOrDefault(\"Ocp-Apim-Subscription-Key\", \"\"))" />
        <set-variable name="originalJwtToken" 
                      value="@(context.Request.Headers.GetValueOrDefault(\"Authorization\", \"\"))" />
        
        <base />
        
        <!-- 3. Rate limiting -->
        <rate-limit calls="100" renewal-period="60" />
        
        <!-- 4. Subscription validation -->
        <choose>
            <when condition="@(context.Subscription == null || context.Subscription.Id == null)">
                <return-response>
                    <set-status code="401" reason="Unauthorized" />
                    <set-header name="Content-Type" exists-action="override">
                        <value>application/json</value>
                    </set-header>
                    <set-body>{"error": "Invalid subscription key"}</set-body>
                </return-response>
            </when>
        </choose>
        
        <!-- 5. Subscription whitelist -->
        <choose>
            <when condition="@(!new string[] {\"authorized-sub-1\", \"authorized-sub-2\"}.Contains(context.Subscription.Id))">
                <return-response>
                    <set-status code="403" reason="Forbidden" />
                    <set-header name="Content-Type" exists-action="override">
                        <value>application/json</value>
                    </set-header>
                    <set-body>{"error": "MCP access not authorized"}</set-body>
                </return-response>
            </when>
        </choose>
        
        <!-- 6. Re-inject headers -->
        <set-header name="Ocp-Apim-Subscription-Key" exists-action="override">
            <value>@((string)context.Variables[\"originalSubKey\"])</value>
        </set-header>
        <set-header name="Authorization" exists-action="override">
            <value>@((string)context.Variables[\"originalJwtToken\"])</value>
        </set-header>
    </inbound>
    <backend>
        <base />
    </backend>
    <outbound>
        <!-- Rate limit headers -->
        <set-header name="X-RateLimit-Limit" exists-action="override">
            <value>100</value>
        </set-header>
        <set-header name="X-RateLimit-Window" exists-action="override">
            <value>60</value>
        </set-header>
        <base />
    </outbound>
    <on-error>
        <base />
    </on-error>
</policies>

Testing Your Security

1. Test Unauthorized Access

# Should fail with 401
curl -X POST https://your-apim.azure-api.net/your-api-mcp/mcp \
  -H "Content-Type: application/json" \
  -d '{"jsonrpc": "2.0", "method": "tools/list", "id": 1}'

2. Test Invalid Subscription Key

# Should fail with 401
curl -X POST https://your-apim.azure-api.net/your-api-mcp/mcp \
  -H "Content-Type: application/json" \
  -H "Ocp-Apim-Subscription-Key: INVALID_KEY" \
  -d '{"jsonrpc": "2.0", "method": "tools/list", "id": 1}'

3. Test Valid Access

# Should succeed with 200
curl -X POST https://your-apim.azure-api.net/your-api-mcp/mcp \
  -H "Content-Type: application/json" \
  -H "Ocp-Apim-Subscription-Key: YOUR_VALID_KEY" \
  -d '{"jsonrpc": "2.0", "method": "tools/list", "id": 1}'

4. Test Rate Limiting

# Rapid requests to trigger 429
for i in {1..150}; do
  curl -s -o /dev/null -w "%{http_code}\n" \
    -X POST https://your-apim.azure-api.net/your-api-mcp/mcp \
    -H "Ocp-Apim-Subscription-Key: YOUR_KEY" \
    -d '{"jsonrpc": "2.0", "method": "tools/list", "id": '$i'}'
done | grep -c "429"
# Should show ~50 (requests 101-150)

Security Checklist

Before going to production:

  • /tools/list requires subscription key
  • Invalid subscription keys return 401
  • Subscription whitelist implemented
  • Rate limiting configured and tested
  • JWT tokens forwarded (if needed)
  • CORS configured correctly (if needed)
  • Rate limit headers returned
  • All security tests passing

What's Next

Security is locked down. Now you need observability.

Coming soon: Part 3 - Audit Logging & Monitoring That Actually Works

In Part 3, I'll show you how to implement audit logging without the request-hanging bugs, plus distributed tracing and error handling.


I'm a Product Architect at Backbase, where I design cloud-native banking platforms serving millions of users. The patterns in this series come from real production implementations at enterprise scale. Views are my own.

Have security patterns you're using for MCP servers? Share them in the comments.

Follow with me on LinkedIn for more content.