Azure Functions Behind API Management: What the Happy Path Diagram Leaves Out

Recently, I noticed another Azure architecture infographic on LinkedIn. Four boxes, left to right: clients, API Management, Function App, backend services. Underneath it, a tidy line. Function App handles the code, API Management handles the control. Together they deliver powerful and secure APIs.

(Source: LinkedIn post)

Nothing in that diagram is wrong. However, it’s not the whole story.

I have reviewed this pattern several times, in reference architectures, in project designs, and in my own work. The four boxes are always right and the design behind them is often not. The gap sits in what the arrows imply rather than in what the boxes say. So let us redraw it, and then walk through the six things the four box version quietly leaves out.

What the original gets right

Credit where it belongs. The separation of concerns in that infographic is sound, and plenty of teams still get it backwards.

API Management owns the contract. It publishes the API, applies policy, meters consumption, and gives consumers something stable to build against. Azure Functions owns the work. It runs your business logic, scales with demand, and bills for execution rather than for uptime.

That split matters because the alternative is worse. Teams that skip the gateway end up implementing authentication, throttling, and versioning inside every function, in slightly different ways, maintained by whoever touched it last. I wrote about a related boundary problem in Azure Functions, Logic Apps, and Power Automate: choosing the right tool, and the same principle applies here. The value of a gateway is not the features it lists. The value is the code it lets you delete.

So the boxes are right. Now for the arrows.

Gap one: a gateway is not a firewall

The original diagram lists “Security (OAuth, API Key)” inside the API Management box and leaves it there. That single bullet does a lot of quiet work, because it invites you to treat the gateway as your perimeter.

API Management applies policy. It validates tokens, enforces quotas, transforms payloads, and rejects malformed requests against a schema. It does not run an OWASP rule set, and it is not designed to absorb a volumetric attack aimed at your public hostname.

For anything internet facing, put Azure Front Door Premium or Application Gateway in front, with a WAF policy attached. Then lock the origin so the gateway only accepts traffic that arrived through the edge. Otherwise you have bought a policy engine and called it a perimeter.

This is also the cheapest gap to close, which makes it the most annoying one to find missing during a penetration test.

Gap two: subscription keys meter, tokens authenticate

Here is the conflation that causes the most damage in practice. A subscription key and an access token appear side by side in most diagrams, as if they were two ways of doing the same thing.

They are not. A subscription key identifies a product. It answers the question “which consumer agreement does this call belong to”, which is a billing and quota question. It does not tell you who the caller is, it travels in a header that ends up in scripts and log files, and it is shared across everyone using that product.

Authentication happens in the validate-jwt policy. That is where you check the signature, the audience, the issuer, and the claims that decide whether this particular caller may create an order.

<validate-jwt header-name="Authorization" failed-validation-httpcode="401">
<openid-config url="https://login.microsoftonline.com/{{entra-tenant-id}}/v2.0/.well-known/openid-configuration" />
<audiences>
<audience>{{orders-api-audience}}</audience>
</audiences>
<required-claims>
<claim name="roles" match="any">
<value>Orders.Write</value>
</claim>
</required-claims>
</validate-jwt>

Once that is in place, the rate limit should follow the same identity. Throttling by IP address punishes everyone behind a corporate NAT and protects you from nobody who has a laptop and patience. Throttling by a claim from the validated token gives you a quota per caller, which is what the consumer actually agreed to.

<rate-limit-by-key calls="60" renewal-period="60"
counter-key="@(context.Request.Headers.GetValueOrDefault("Authorization","").AsJwt()?.Claims.GetValueOrDefault("oid", "anonymous"))" />

Gap three: the arrow nobody draws

Now for the one that matters most.

Every version of this diagram shows one arrow into the Function App. That arrow implies the gateway is the way in. It is not. It is a way in.

An HTTP triggered function sits on a public hostname by default. Anyone holding that hostname and a function key can call it directly, and that call skips the WAF, the token validation, the rate limit, the audit trail, and every policy you carefully wrote. The gateway becomes a convention rather than a control, and conventions do not survive contact with an incident.

Teams close this in three stages, usually in this order.

Layer one, the function key. Store the host key as a secret named value in API Management, backed by Key Vault, and let the backend inject it as x-functions-key. The key never appears in your policy file or your repository. This stops crawlers and casual discovery. It does not stop anyone who has ever seen the key, and keys have a way of ending up in Postman collections, log files, and support tickets.

Layer two, token validation on the function itself. Turn on the built-in authentication, point it at the app registration that represents your API, and an untokened call now fails at the platform before your code runs. Better. Still not closed, because a valid token replayed straight at the function hostname bypasses everything the gateway added on top.

Layer three, take it off the internet. Set publicNetworkAccess to Disabled, put a private endpoint in front of the app, and integrate the gateway into the same virtual network. The hostname stops resolving from outside. Now there is genuinely one route in.

resource functionApp 'Microsoft.Web/sites@2023-12-01' = {
properties: {
publicNetworkAccess: networkIsolation ? 'Disabled' : 'Enabled'
virtualNetworkSubnetId: networkIsolation ? functionSubnetId : null
}
}

Layer three is the only one that turns your architecture diagram into a statement about reality. It also has a prerequisite that catches people out, which is the next gap.

Gap four: the tier decides the architecture

Private networking is not a checkbox you add at the end. It is gated by the API Management tier and by the Functions hosting plan, and those two choices constrain everything else in the design.

The v2 tiers changed the arithmetic here. Outbound virtual network integration used to mean Premium, which put private connectivity out of reach for a lot of internal APIs. Standard v2 brought it within reach of an ordinary project budget. On the compute side, the Flex Consumption plan brought virtual network integration to a consumption billing model, which used to mean choosing between cost and connectivity.

The same is true of time. Both the gateway and the function cap how long a request may run, and those caps differ by tier and plan. If your design assumes a two minute synchronous call, you have made a tier decision without realising it.

Pick the tier and the hosting plan before you pick the features, and verify the current limits on Microsoft Learn rather than trusting a diagram. Both sides of this moved several times during 2025 and 2026, and anything I write here has a shelf life.

Gap five: the dashed response arrow assumes synchronous

Look at the original infographic again. The response arrow is dashed and runs straight back to the client. It quietly assumes every call finishes inside the request.

Plenty do not. Bulk imports, report generation, anything that fans out to a slow downstream system. The instinct is to raise the timeout, first on the function, then on the gateway, until the whole chain waits for the slowest possible caller. That is not a fix. It is a queue with worse ergonomics, and it fails under load rather than under test.

The pattern that works is the asynchronous HTTP API. Accept the request, start the work, and answer immediately.

var instanceId = await durableClient.ScheduleNewOrchestrationInstanceAsync(
nameof(BulkImportOrchestrator), orders);
var response = request.CreateResponse(HttpStatusCode.Accepted);
response.Headers.Add("Location", $"{request.PublicBaseUrl()}/bulk/{instanceId}");
response.Headers.Add("Retry-After", "5");

One detail deserves attention, because it connects back to gap three. Durable Functions ships a helper called CreateCheckStatusResponse that builds the polling URLs for you. Those URLs point at the function hostname. That leaks your backend to every caller, and it breaks the moment you disable public network access.

So build the status URL from a setting that holds the gateway address instead. Callers should never learn the name of your function app, and they certainly should not be given it in a header.

Gap six: one request, two telemetry stores

Both boxes in the diagram write to Application Insights, usually to two separate resources with two separate sampling configurations. Neither picture shows that, because telemetry is drawn as a property rather than as a system.

The result shows up during your first real incident. You have a gateway trace that ends at the backend call and a function trace that starts somewhere in the middle, and no reliable way to join them. Then somebody discovers that the gateway sampled at one rate and the function at another, so half the pairs do not exist at all.

Two habits fix this. Set a correlation id at the gateway when the caller did not supply one, and propagate it through the function into every log line.

<set-variable name="correlationId"
value="@(context.Request.Headers.GetValueOrDefault("x-correlation-id", context.RequestId.ToString()))" />
<set-header name="x-correlation-id" exists-action="override">
<value>@((string)context.Variables["correlationId"])</value>
</set-header>

Then align the sampling on both sides, and write the query that joins them before you need it rather than during an outage. I made a similar argument about governed telemetry in the Foundry Citadel Platform series, where an SDK routed its calls around the gateway entirely and the only reason we noticed was that the traces did not add up.

Where this is the wrong answer

Everything above assumes you need API Management. Often you do not.

A gateway earns its place when there is a portfolio to govern. Several APIs, consumers outside your own team, versions that have to coexist, quota that differs per consumer, one place where policy and audit live. Under those conditions API Management pays for itself in the coordination it removes.

For a single internal API with one consumer, it is a monthly bill wrapped around a proxy. Built-in authentication with Entra ID on the function, a private endpoint if it is internal, and you are done. Add the gateway when the second consumer actually appears, because that is when versioning and per consumer quota start to matter.

There is also a middle answer that gets skipped. One public API that needs a WAF but has no consumer lifecycle should sit behind Front Door and nothing else. You get the perimeter without buying governance you are not using yet.

A sample you can deploy

I put the whole thing in a repository, because policy fragments in a blog post are easy to agree with and harder to run.

One azd up deploys API Management Standard v2, a Flex Consumption function app on .NET 8 isolated, a storage account with shared key access disabled, and a shared Application Insights resource. The Orders API has a synchronous endpoint, an asynchronous bulk import built on Durable Functions, and a health endpoint the gateway can probe.

The three layers from gap three are deployment switches rather than prose:

SettingWhat it turns on
defaultLayer one, function key held as a secret named value
ENTRA_TENANT_ID and API_AUDIENCELayer two, validate-jwt and quota by oid claim
NETWORK_ISOLATION=trueLayer three, private endpoint and public access disabled

Deploy it with the default settings, then call the function hostname directly and watch it answer. That single curl makes the argument better than this whole post does.

Closing

The four box diagram is a good summary and a bad specification. Function App handles the code, API Management handles the contract, and neither of them handles the network. That last part is where these designs usually fail, and it is the part no infographic ever draws.

Citadel APIM Kill Switch: Stop a Governed Agent Cold

In the previous post we added conversation persistence to the Microsoft Foundry Citadel Platform on Azure. As a result, every agent run now produces a structured document in the spoke’s Cosmos DB conversations container. The agent is fully operational: it routes through the APIM governance hub, executes tool calls, stores its history, and returns grounded responses. However, the question that every enterprise AI architect eventually faces remains: what happens when it needs to stop?

Not a graceful shutdown. Not a redeployment. An immediate, operator-triggered containment the kind you need when an agent is behaving unexpectedly, consuming runaway tokens, or has been flagged by your security team. In a Microsoft Foundry Citadel Platform on Azure deployment, the answer is the Kill Switch: a layered containment system built into the APIM hub that stops agent traffic cold without touching the agent code, the spoke, or the Azure OpenAI deployment.

This post implements three of the five Citadel kill switch layers against the hub we deployed in Sweden Central:

  • Layer 1 — Named Value flip: instant global block via a single boolean
  • Layer 2 — JWT claim block: identity-based containment via header validation
  • Layer 3 — Agent ID blocklist: surgical per-agent blocking

The Scenario

The weather agent (agent_with_memory.py) is running in production. Specifically, it is routing through apim-wpvlimv4ngkns.azure-api.net, storing conversations in the spoke Cosmos DB, and generating token usage events in the hub Cosmos DB. Everything is working. Then your security team flags it. The agent needs to stop immediately while the incident is investigated. You have seconds, not minutes. For example, redeploying the spoke takes too long. Rotating the APIM subscription key is irreversible and affects all consumers. Therefore, the Kill Switch is the right tool.

The Kill Switch is the right tool. The APIM hub has built-in pre-wiring, requires no code changes, and can trigger actions in under 30 seconds.

To ensure reliability, always pre-wire the kill switch as Layer 1 before you need it. Remember, you can’t flip a Named Value that doesn’t exist. In addition, the inbound policy must already be in place, checking the Named Value on every request, before any incident occurs.

Prerequisites

From the previous posts you should have:

  • Hub deployed in rg-ai-hub-gateway-dev with APIM instance apim-wpvlimv4ngkns
  • agent_with_memory.py running and saving to Cosmos DB
  • Azure CLI authenticated

Citadel Kill Switch Layer 1 — Named Value Flip

How It Works

A Named Value called kill-switch-enabled is created in APIM and set to false. An inbound policy on the OpenAI API checks this value on every request. When the value is flipped to true, all requests through the gateway immediately return HTTP 403 — no code changes, no redeployment, no spoke involvement.

Step 1.1 — Create the Named Value

az apim nv create `
--resource-group rg-ai-hub-gateway-dev `
--service-name apim-wpvlimv4ngkns `
--named-value-id kill-switch-enabled `
--display-name "kill-switch-enabled" `
--value "false" `
--secret false

Verify it was created:

az apim nv show `
--resource-group rg-ai-hub-gateway-dev `
--service-name apim-wpvlimv4ngkns `
--named-value-id kill-switch-enabled `
--query "value" -o tsv

Should return false.

Step 1.2 — Add the Inbound Policy

In the Azure Portal:

  1. Navigate to apim-wpvlimv4ngkns → APIs → Azure OpenAI Service API → All operations
  2. Click Policies → Inbound processing → Edit
  3. Add this policy inside the <inbound> section, before any other policies:
<!-- Kill Switch Layer 1: Named Value flip -->
<set-variable name="killSwitchActive" value="@("{{kill-switch-enabled}}" == "true")" />
<choose>
<when condition="@((bool)context.Variables["killSwitchActive"])">
<return-response>
<set-status code="403" reason="Agent Suspended" />
<set-header name="Content-Type" exists-action="override">
<value>application/json</value>
</set-header>
<set-header name="x-kill-switch-layer" exists-action="override">
<value>1-named-value</value>
</set-header>
<set-body>{"error": {"code": "KillSwitchActive", "message": "Agent access has been suspended by the governance hub.", "layer": 1}}</set-body>
</return-response>
</when>
</choose>

The dedicated Named Value check policy provides a cleaner approach.

<!-- Kill Switch Layer 1: Named Value flip -->
<!-- Pre-wire this BEFORE any incident. Set kill-switch-enabled=true to activate. -->
<set-variable name="killSwitchActive" value="@("{{kill-switch-enabled}}" == "true")" />
<choose>
<when condition="@((bool)context.Variables["killSwitchActive"])">
<return-response>
<set-status code="403" reason="Agent Suspended" />
<set-header name="Content-Type" exists-action="override">
<value>application/json</value>
</set-header>
<set-header name="x-kill-switch-layer" exists-action="override">
<value>1-named-value</value>
</set-header>
<set-body>{"error": {"code": "KillSwitchActive", "message": "Agent access has been suspended by the governance hub. Contact your administrator.", "layer": 1}}</set-body>
</return-response>
</when>
</choose>

Click Save.

Step 1.3 — Confirm Agent Runs Normally

With kill-switch-enabled set to false, the agent should still work:

python agent_with_memory.py

Expected output: normal run, conversation saved, answer returned.

Step 1.4 — Trigger the Kill Switch

az apim nv update `
--resource-group rg-ai-hub-gateway-dev `
--service-name apim-wpvlimv4ngkns `
--named-value-id kill-switch-enabled `
--value "true"

Now run the agent:

python agent_with_memory.py

Expected output:

The agent stops. No spoke changes occur. No code changes happen. One CLI command executes.

Step 1.5 — Reset

az apim nv update `
--resource-group rg-ai-hub-gateway-dev `
--service-name apim-wpvlimv4ngkns `
--named-value-id kill-switch-enabled `
--value "false"

Citadel Kill Switch Layer 2 — Agent Approval Header

How It Works

The agent is required to pass a custom header x-agent-token containing a signed JWT with a specific claim (agt-approved: true). The APIM inbound policy validates this claim. If the claim is absent or the token is invalid, the system blocks the request with a 401 status. This action simulates identity-based containment, revoking the agent’s token or invalidating its claim at the identity provider level.

Step 2.1 — Update the Agent to Send a Header

Add the x-agent-token header to agent_with_memory.py. In this demo, we simulate the token by using a simple header value. IIn a production environment, Entra ID issues a JWT.

Modify the AzureOpenAI client creation in agent_with_memory.py:

client = AzureOpenAI(
azure_endpoint=apim_base,
api_key=cfg["APIM_SUBSCRIPTION_KEY"],
api_version="2024-02-01",
default_headers={
"x-agent-id": "citadel-weather-agent-v1"
}
)

Step 2.2 — Add the JWT Claim Check Policy

In the portal, add this policy after the Layer 1 block in the inbound section:

<!-- Kill Switch Layer 2: Agent approval header check -->
<choose>
<when condition="@(context.Request.Headers.GetValueOrDefault("x-agent-approved", "false") != "true")">
<return-response>
<set-status code="401" reason="Agent Not Approved" />
<set-header name="Content-Type" exists-action="override">
<value>application/json</value>
</set-header>
<set-header name="x-kill-switch-layer" exists-action="override">
<value>2-agent-approval</value>
</set-header>
<set-body>{"error": {"code": "AgentNotApproved", "message": "Agent identity could not be verified. Approval header missing or invalid.", "layer": 2}}</set-body>
</return-response>
</when>
</choose>

Step 2.3 — Trigger Layer 2

Remove the x-agent-approved header from the agent (or set it to false) and run:

python agent_with_memory.py

Expected output:

Note the response header x-kill-switch-layer: 2-agent-approval this indicates which containment layer fired and is critical for incident triage.

Pitfall: Policy Order Matters

Layer 1 must appear before Layer 2 in the policy document. APIM evaluates inbound policies top to bottom and stops at the first <return-response>. If Layer 2 appears before Layer 1, a globally suspended agent would return a 401 (identity error) instead of a 403 (suspended), obscuring the true containment reason in your incident log.

Citadel Kill Switch Layer 3 — Agent ID Blocklist in APIM

How It Works

A Named Value called blocked-agent-ids holds a comma-separated list of agent IDs. The inbound policy checks the x-agent-id header against this list. When agents match, the system blocks them with a 403 status code. Non-matching agents continue operating normally. This approach allows for surgical containment, stopping one specific agent while allowing all others to function.

Step 3.1 — Create the Blocklist Named Value

az apim nv create `
--resource-group rg-ai-hub-gateway-dev `
--service-name apim-wpvlimv4ngkns `
--named-value-id blocked-agent-ids `
--display-name "blocked-agent-ids" `
--value "none" `
--secret false

Start with an empty value — no agents blocked.

Step 3.2 — Add the Blocklist Policy

Add this policy after Layer 2 in the inbound section:

<!-- Kill Switch Layer 3: Agent ID blocklist -->
<set-variable name="agentId" value="@(context.Request.Headers.GetValueOrDefault("x-agent-id", ""))" />
<set-variable name="blockedIds" value="@("{{blocked-agent-ids}}")" />
<choose>
<when condition="@{
var agentId = (string)context.Variables["agentId"];
var blockedIds = (string)context.Variables["blockedIds"];
if (string.IsNullOrEmpty(agentId) || string.IsNullOrEmpty(blockedIds)) { return false; }
return blockedIds.Split(',').Any(id => id.Trim() == agentId.Trim());
}">
<return-response>
<set-status code="403" reason="Agent Blocked" />
<set-header name="Content-Type" exists-action="override">
<value>application/json</value>
</set-header>
<set-header name="x-kill-switch-layer" exists-action="override">
<value>3-agent-blocklist</value>
</set-header>
<set-body>{"error": {"code": "AgentBlocked", "message": "Agent has been added to the governance blocklist.", "layer": 3}}</set-body>
</return-response>
</when>
</choose>

Step 3.3 — Add the Agent to the Blocklist

az apim nv update `
--resource-group rg-ai-hub-gateway-dev `
--service-name apim-wpvlimv4ngkns `
--named-value-id blocked-agent-ids `
--value "citadel-weather-agent-v1"

Run the agent:

python agent_with_memory.py

Expected output:

Step 3.4 — Surgical Validation

The power of Layer 3 is specificity. If you had a second agent with a different x-agent-id say citadel-docs-agent-v1 it would pass through Layer 3 unaffected while citadel-weather-agent-v1 remains blocked. One agent stopped, all others running. This is the enterprise AI governance pattern: granular control without broad disruption.

Remove the agent from the blocklist:

az apim nv update --resource-group rg-ai-hub-gateway-dev
--service-name apim-wpvlimv4ngkns --named-value-id blocked-agent-ids
--value "none"

Validating the Citadel Kill Switch in Application Insights

Rather than using the CLI — which has a 5–10 minute Log Analytics ingestion lag — go directly to Application Insights in the portal for immediate results:

  1. Portal → appi-apim-wpvlimv4ngkns in rg-ai-hub-gateway-dev
  2. Left sidebar → Logs
  3. Paste and run this query:
requests
| where timestamp > ago(2h)
| where resultCode in ("200", "401", "403")
| project timestamp, resultCode, duration, name
| order by timestamp desc

The results table tells the complete kill switch story in two columns — resultCode and duration:

The duration contrast is the definitive proof that the kill switch works as designed. The 401s and 403s resolve in under 50ms, stopped cold at the APIM inbound policy before a single token is sent to Azure OpenAI. The 200s take 683ms–2010ms because they made the full round trip through the governance hub to Azure OpenAI and back.

Zero tokens consumed on blocked requests, zero cost, and zero Cosmos DB writes in the spoke. The agent is stopped at the perimeter.

For a sharper view that highlights exactly which kill switch layer fired on each blocked request, add the response header to the query. Unfortunately APIM response headers are not automatically projected into the requests table in Application Insights — but you can distinguish the layers by combining result code and timing:

requests
| where timestamp > ago(2h)
| where resultCode in ("200", "401", "403")
| extend killSwitchLayer = case(
resultCode == "401", "Layer 2 — agent approval",
resultCode == "403" and duration < 10, "Layer 1 or 3 — gateway block",
resultCode == "200", "Normal — LLM call completed",
"Unknown"
)
| project timestamp, resultCode, duration, killSwitchLayer
| order by timestamp desc

This gives you a readable incident log showing which containment layer was active at each point in time, directly useful for DORA incident post-mortem documentation and EU AI Act Article 17 risk management records.

The Complete Three-Layer Kill Switch Policy

Here is the complete inbound policy block containing all three layers, ready to paste into APIM:

 <!-- Kill Switch Layer 1: Named Value flip -->
        <set-variable name="killSwitchActive" value="@("{{kill-switch-enabled}}" == "true")" />
        <choose>
            <when condition="@((bool)context.Variables["killSwitchActive"])">
                <return-response>
                    <set-status code="403" reason="Agent Suspended" />
                    <set-header name="Content-Type" exists-action="override">
                        <value>application/json</value>
                    </set-header>
                    <set-header name="x-kill-switch-layer" exists-action="override">
                        <value>1-named-value</value>
                    </set-header>
                    <set-body>{"error": {"code": "KillSwitchActive", "message": "Agent access has been suspended by the governance hub.", "layer": 1}}</set-body>
                </return-response>
            </when>
        </choose>
        <!-- Kill Switch Layer 2: Agent approval header -->
        <choose>
            <when condition="@(context.Request.Headers.GetValueOrDefault("x-agent-approved", "false") != "true")">
                <return-response>
                    <set-status code="401" reason="Agent Not Approved" />
                    <set-header name="Content-Type" exists-action="override">
                        <value>application/json</value>
                    </set-header>
                    <set-header name="x-kill-switch-layer" exists-action="override">
                        <value>2-agent-approval</value>
                    </set-header>
                    <set-body>{"error": {"code": "AgentNotApproved", "message": "Agent identity could not be verified. Approval header missing or invalid.", "layer": 2}}</set-body>
                </return-response>
            </when>
        </choose>
        <!-- Kill Switch Layer 3: Agent ID blocklist -->
        <set-variable name="agentId" value="@(context.Request.Headers.GetValueOrDefault("x-agent-id", ""))" />
        <set-variable name="blockedIds" value="@("{{blocked-agent-ids}}")" />
        <choose>
            <when condition="@{
        var agentId = (string)context.Variables["agentId"];
        var blockedIds = (string)context.Variables["blockedIds"];
        if (string.IsNullOrEmpty(agentId) || string.IsNullOrEmpty(blockedIds)) { return false; }
        return blockedIds.Split(',').Any(id => id.Trim() == agentId.Trim());
    }">
                <return-response>
                    <set-status code="403" reason="Agent Blocked" />
                    <set-header name="Content-Type" exists-action="override">
                        <value>application/json</value>
                    </set-header>
                    <set-header name="x-kill-switch-layer" exists-action="override">
                        <value>3-agent-blocklist</value>
                    </set-header>
                    <set-body>{"error": {"code": "AgentBlocked", "message": "Agent has been added to the governance blocklist.", "layer": 3}}</set-body>
                </return-response>
            </when>
        </choose>

Pitfalls Summary

PitfallFix
Named Value doesn’t exist at incident timePre-wire Layer 1 during normal operations — never during an incident
Policy evaluation error on {{kill-switch-enabled}}Named Value must exist before the policy referencing it is saved
Layer 2 fires before Layer 1 in policyPolicy order matters — Layer 1 must be first in the inbound block
Agent ID header not sentAdd x-agent-id to default_headers in AzureOpenAI client
Blocklist with trailing spaces blocks nothingUse .Trim() in the policy C# expression when splitting
Kill switch left active after testAlways reset Named Values after testing — kill-switch-enabled=false, blocked-agent-ids=""

What the Kill Switch Demonstrates About Citadel

The three layers reveal something important about the Citadel architecture: governance lives in the hub, not the agent. The agent code has no knowledge of the kill switch. The spoke has no kill switch configuration. The Azure OpenAI deployment is untouched. All containment logic is in the APIM hub’s inbound policy — one place, centrally managed, instantly effective.

This is the enterprise AI control plane pattern in practice. When an incident occurs:

  • Layer 1 stops everything immediately while you triage
  • Layer 2 enforces identity verification once normal operations resume
  • Layer 3 surgically targets the offending agent while other agents continue

The x-kill-switch-layer response header ensures your incident log captures exactly which containment mechanism fired, giving you a clean audit trail for post-mortem analysis — directly relevant for DORA incident reporting and EU AI Act Article 17 risk management documentation.

What’s Next

The next post in this series takes the dev setup and hardens it for non-prod: networkIsolation=true, APIM Premium SKU, per-spoke subscription keys with independent quotas, and Azure Policy at the management group level. The kill switch policies we built here carry forward unchanged governance in the hub environment, which is environment-agnostic.

Azure API Management as MCP Gateway: Governing Agentic AI Workloads

Part 7 of 7 in the “APIM for AI Workloads” series

Azure API Management as MCP gateway is the natural endpoint of everything this series has built. In Parts 1 through 6, we established APIM as the control plane for AI workloads: securing access, limiting and measuring token consumption, routing traffic resiliently across backends, and reducing costs through semantic caching. All of that applies equally to agentic workloads. The difference is that agents introduce a new communication pattern: the Model Context Protocol (MCP), which standardizes how AI agents discover and call tools.

In my work and online research on agentic AI architecture, I consistently returned to the same question: how does one govern agent tool calls with the same rigor we apply to API calls? The answer, increasingly, is that APIM handles both. This post covers what that looks like in practice.

What MCP Is and Why It Changes the APIM Story

MCP is an open protocol, originally developed by Anthropic, that defines a standard interface between AI agents (MCP clients) and the tools they call (MCP servers). Instead of each agent framework implementing its own bespoke tool-calling mechanism, MCP gives agents a consistent way to discover available tools, understand their input schemas, and invoke them. Frameworks including Semantic Kernel, AutoGen, and LangGraph are all adding MCP client support.

For APIM, MCP matters because it transforms the gateway from a proxy for AI completions into a broker for agent tool calls. An agent no longer calls your internal APIs directly. Instead, it discovers them as MCP tools through APIM, and APIM enforces the same governance policies on those tool calls that it enforces on any other request. The control plane extends naturally into the agentic layer.

Azure API Management as MCP Gateway: Three Capabilities

APIM’s MCP gateway capabilities fall into three categories:

Expose REST APIs as MCP servers. The export-rest-mcp-server policy takes any API already registered in your APIM catalog and auto-generates MCP tool definitions from it. An agent connecting to your APIM MCP endpoint discovers those tools via the standard MCP protocol and can call them without any knowledge of the underlying REST implementation. Crucially, no changes are required to the underlying API. The policy handles the translation layer entirely within APIM.

Pass through external MCP servers. APIM can proxy external MCP servers — whether third-party services like GitHub or Jira, or custom MCP servers built by your own teams — through the same gateway. All traffic passes through APIM’s policy pipeline, so you apply JWT validation, subscription key enforcement, token limits, and logging to external MCP calls exactly as you would to any other API call. Agents get a single APIM endpoint; APIM handles the routing.

Agent-to-agent (A2A) traffic. In multi-agent architectures, orchestrator agents call sub-agents to delegate tasks. Routing that traffic through APIM means every A2A hop is governed: authenticated, rate-limited, logged, and subject to the same token budget controls applied to end-user traffic. This is particularly relevant for agentic pipelines running on Microsoft Foundry, where multiple specialized agents collaborate within a single workflow.

Applying Series Policies to Agentic Workloads

One of the practical advantages of routing MCP traffic through APIM is that every policy covered in this series applies without modification. Agentic workloads are not a special case requiring a separate governance layer. They use the same pipeline.

  • Authentication (Part 2): Agents authenticate to APIM using subscription keys or JWT tokens. APIM authenticates to AI backends via Managed Identity. The agent never holds backend credentials.
  • Token limits (Part 3): Multi-step agentic pipelines can consume large token volumes per workflow. Per-subscription TPM limits prevent a single runaway pipeline from exhausting shared capacity.
  • Token metrics (Part 4): Token consumption from agentic workflows is attributed to the subscribing team or pipeline via the emit-token-metric policy. FinOps visibility extends automatically to agentic workloads.
  • Load balancing (Part 5): Agentic pipelines often run longer and consume more tokens per call than chat applications. PTU-to-PAYG failover protects pipeline continuity when primary capacity saturates.
  • Semantic caching (Part 6): Agents that make repeated identical tool calls, checking a status, or looking up a reference value, benefit from semantic caching in the same way chat applications do.

Practical Considerations for APIM as MCP Gateway

A few agentic-specific considerations are worth calling out before you start routing MCP traffic through APIM.

Tool discovery latency. MCP clients typically discover available tools at session start by calling the MCP server’s tool list endpoint. With APIM in the path, that discovery call passes through the full policy pipeline. Keep your inbound policies lightweight for discovery calls, or cache the tool list response to avoid repeated round trips.

Streaming responses. Many AI completions endpoints support streaming via server-sent events. APIM supports streaming passthrough, but some policies — including semantic cache lookup — do not apply to streaming responses. Structure your pipeline accordingly: apply caching only to non-streaming completion calls.

Session state. MCP conversations are stateful within a session. APIM is stateless between requests, so per-session state must live in the calling agent or an external store. The vary-by pattern from the semantic cache policy can scope cached tool responses by session ID if the agent passes one in a header.

Token budget propagation. In multi-agent pipelines, token budgets need to propagate from the orchestrator to sub-agents. Exposing the remaining token budget from the remaining-tokens-variable-name attribute (Part 3) as a response header lets orchestration frameworks like Semantic Kernel make informed decisions about which sub-agent to invoke next.

Azure API Management as MCP Gateway: Closing the Series

This post closes the series, but the control plane it describes is not static. MCP is still evolving rapidly. New APIM policy capabilities for agentic workloads are shipping frequently. The architecture board conversation at various enterprise has shifted from “should we centralize AI traffic through APIM?” to “what do we govern next?”, which is a good place to be.

Looking back across the seven posts, the consistent theme is that AI workloads are not fundamentally different from other API workloads in terms of governance requirements. They need authentication, rate limiting, observability, resilience, and cost control. APIM provides all of those. What changes with AI is the unit of measurement (tokens, not requests), the billing model (PTU vs. PAYG), and now the communication protocol (MCP for agents). The control plane adapts to each of these without requiring a parallel governance infrastructure.

The full series index is below for reference. Each post links to the relevant Microsoft documentation and includes policy XML you can use directly.

  • Part 1: Why your AI APIs need a gateway.
  • Part 2: Authentication and authorization.
  • Part 3: Token limit policy.
  • Part 4: Token metric policy and cross-charging.
  • Part 5: Load balancing and circuit breaking.
  • Part 6: Semantic caching.

Part 7 (this post): APIM as MCP gateway for agentic AI workloads.