July 12, 2026
Securing Your MCP Server: Microsoft Entra ID + Azure API Management
Anyone with the URL could call your tools. That ends in this article.

By Divyesh Govaerdhanan
9 min read
Part 3 left us with a real problem: the AzureOps MCP Server is deployed, returning real Azure data, authenticated to Azure Resource Manager via Managed Identity, and completely open to the internet. Anyone with the Container App URL can invoke get_deployment_status against any subscription ID they want.
This article closes that gap with two layers that work together, not one silver bullet:
Layer 1 — Identity: Microsoft Entra ID issues tokens. Azure API Management validates them before any request reaches your server. No valid token, no access.
Layer 2 — Network: The Container App is locked down to accept traffic only from APIM. Even a valid-looking request that bypasses APIM gets dropped at the network level.
Most MCP security write-ups stop at Layer 1. That leaves a gap: if your Container App's public URL is still reachable directly, a determined caller can skip APIM entirely. We close both.
By the end, we'll also add per-tool authorization, so a caller with access to get_deployment_status doesn't automatically get get_resource_tags. Least privilege, not just least friction.
The Architecture
VS Code / Copilot Agent Mode
│
│ 1. No token → 401 + WWW-Authenticate header
│ 2. Discovers Entra ID via Protected Resource Metadata
│ 3. User signs in, gets access token
▼
Microsoft Entra ID ──issues──> Access Token (JWT)
│
│ Authorization: Bearer <token>
▼
Azure API Management
│
│ validate-azure-ad-token policy checks:
│ - Token signature & expiry
│ - Correct tenant
│ - Correct client application ID
│
▼
Azure Container App (network-locked: only accepts traffic from APIM)
│
│ Reads forwarded Authorization header
│ Checks App Role claim before executing tool
▼
Tool executes → calls ARM via Managed IdentityVS Code / Copilot Agent Mode
│
│ 1. No token → 401 + WWW-Authenticate header
│ 2. Discovers Entra ID via Protected Resource Metadata
│ 3. User signs in, gets access token
▼
Microsoft Entra ID ──issues──> Access Token (JWT)
│
│ Authorization: Bearer <token>
▼
Azure API Management
│
│ validate-azure-ad-token policy checks:
│ - Token signature & expiry
│ - Correct tenant
│ - Correct client application ID
│
▼
Azure Container App (network-locked: only accepts traffic from APIM)
│
│ Reads forwarded Authorization header
│ Checks App Role claim before executing tool
▼
Tool executes → calls ARM via Managed IdentityTwo identities are in play here, and conflating them is the most common mistake: the caller's identity (validated by Entra ID + APIM, which determines who can invoke which tool) and the server's identity (the Container App's Managed Identity, used to call Azure Resource Manager). We are not using the On-Behalf-Of token exchange in this article. The tools don't act as the calling user against Azure Resource Manager. They act as the app, using the same Managed Identity from Part 3. We're deciding whether to let the call through at all and which tools the caller may use. That's a deliberate scope decision, not an oversight — OBO adds real complexity for a benefit this scenario doesn't need.
Phase 1 — Register the MCP Server in Entra ID
The server needs its own app registration. This represents your API to Entra ID and defines what a caller must present to be granted access.
# Create the server app registration
az ad app create \
--display-name "AzureOps MCP Server" \
--sign-in-audience AzureADMyOrg
$SERVER_APP_ID=$(az ad app list --display-name "AzureOps MCP Server" --query "[0].appId" -o tsv)
# Set the Application ID URI - this becomes the audience for tokens
az ad app update \
--id $SERVER_APP_ID \
--identifier-uris "api://$SERVER_APP_ID"
# Create the service principal
az ad sp create --id $SERVER_APP_ID# Create the server app registration
az ad app create \
--display-name "AzureOps MCP Server" \
--sign-in-audience AzureADMyOrg
$SERVER_APP_ID=$(az ad app list --display-name "AzureOps MCP Server" --query "[0].appId" -o tsv)
# Set the Application ID URI - this becomes the audience for tokens
az ad app update \
--id $SERVER_APP_ID \
--identifier-uris "api://$SERVER_APP_ID"
# Create the service principal
az ad sp create --id $SERVER_APP_ID1.1 — Expose an API Scope
This defines the permission a client requests to call your server at all.
az ad app update --id $SERVER_APP_ID --set api='{
"oauth2PermissionScopes": [{
"id": "<GENERATE A GUID AND REPLACE>",
"adminConsentDisplayName": "Access AzureOps MCP Server",
"adminConsentDescription": "Allows the app to call AzureOps MCP tools on behalf of the signed-in user",
"userConsentDisplayName": "Access AzureOps MCP Server",
"userConsentDescription": "Allow this app to call AzureOps MCP tools",
"value": "access_as_user",
"type": "User",
"isEnabled": true
}]
}'az ad app update --id $SERVER_APP_ID --set api='{
"oauth2PermissionScopes": [{
"id": "<GENERATE A GUID AND REPLACE>",
"adminConsentDisplayName": "Access AzureOps MCP Server",
"adminConsentDescription": "Allows the app to call AzureOps MCP tools on behalf of the signed-in user",
"userConsentDisplayName": "Access AzureOps MCP Server",
"userConsentDescription": "Allow this app to call AzureOps MCP tools",
"value": "access_as_user",
"type": "User",
"isEnabled": true
}]
}'1.2 — Define App Roles for Per-Tool Authorization
This is the piece most MCP security articles skip entirely. A valid token proves who the caller is — it doesn't say which tools they should reach. We define two roles: one for read-only operational visibility, one for FinOps-sensitive tag data.
az ad app update --id $SERVER_APP_ID --set appRoles='[
{
"id": "a11c2b44-27ad-4be3-87e9-03868835976a",
"displayName": "AzureOps.Operations.Read",
"description": "Can call deployment status and health check tools",
"value": "AzureOps.Operations.Read",
"allowedMemberTypes": ["User", "Application"],
"isEnabled": true
},
{
"id": "e1d54777-9aa9-41ad-b26c-79344058a9e2",
"displayName": "AzureOps.FinOps.Read",
"description": "Can call the resource tag compliance tool",
"value": "AzureOps.FinOps.Read",
"allowedMemberTypes": ["User", "Application"],
"isEnabled": true
}
]'az ad app update --id $SERVER_APP_ID --set appRoles='[
{
"id": "a11c2b44-27ad-4be3-87e9-03868835976a",
"displayName": "AzureOps.Operations.Read",
"description": "Can call deployment status and health check tools",
"value": "AzureOps.Operations.Read",
"allowedMemberTypes": ["User", "Application"],
"isEnabled": true
},
{
"id": "e1d54777-9aa9-41ad-b26c-79344058a9e2",
"displayName": "AzureOps.FinOps.Read",
"description": "Can call the resource tag compliance tool",
"value": "AzureOps.FinOps.Read",
"allowedMemberTypes": ["User", "Application"],
"isEnabled": true
}
]'Assign yourself the roles for testing:
$USER_OBJECT_ID=$(az ad signed-in-user show --query id -o tsv)
$SP_OBJECT_ID=$(az ad sp show --id $SERVER_APP_ID --query id -o tsv)
az rest --method POST \
--uri "https://graph.microsoft.com/v1.0/servicePrincipals/$SP_OBJECT_ID/appRoleAssignedTo" \
--headers "Content-Type=application/json" \
--body "{'principalId':'$USER_OBJECT_ID','resourceId':'$SP_OBJECT_ID','appRoleId':'e1d54777-9aa9-41ad-b26c-79344058a9e2'}"$USER_OBJECT_ID=$(az ad signed-in-user show --query id -o tsv)
$SP_OBJECT_ID=$(az ad sp show --id $SERVER_APP_ID --query id -o tsv)
az rest --method POST \
--uri "https://graph.microsoft.com/v1.0/servicePrincipals/$SP_OBJECT_ID/appRoleAssignedTo" \
--headers "Content-Type=application/json" \
--body "{'principalId':'$USER_OBJECT_ID','resourceId':'$SP_OBJECT_ID','appRoleId':'e1d54777-9aa9-41ad-b26c-79344058a9e2'}"In production, you'd assign these roles to Entra security groups, not individual users — group-based assignment scales without per-person app registration changes.
One thing to watch: the az ad app update --set appRoles=... command replaces the entire appRoles array. If you're adding a role to an app that already has roles defined, fetch the existing array first and append to it — running this command twice with different role lists will silently delete the roles from the first run.
1.3 — Register VS Code as a Pre-Authorized Client
Entra ID doesn't support OAuth Dynamic Client Registration. Every client that calls your MCP server must be explicitly known — this is the most important limitation to understand before going further. For VS Code / GitHub Copilot, register a separate client app:
az ad app create \
--display-name "AzureOps MCP - VS Code Client" \
--is-fallback-public-client true \
--public-client-redirect-uris "http://localhost" "http://127.0.0.1"
$CLIENT_APP_ID=$(az ad app list --display-name "AzureOps MCP - VS Code Client" --query "[0].appId" -o tsv)
# Pre-authorize this client to call the server's access_as_user scope
$SCOPE_ID=$(az ad app show --id $SERVER_APP_ID --query "api.oauth2PermissionScopes[0].id" -o tsv)
az ad app update --id "$SERVER_APP_ID" --set api="{
'preAuthorizedApplications': [{
'appId': '$CLIENT_APP_ID',
'delegatedPermissionIds': ['$SCOPE_ID']
}]
}"az ad app create \
--display-name "AzureOps MCP - VS Code Client" \
--is-fallback-public-client true \
--public-client-redirect-uris "http://localhost" "http://127.0.0.1"
$CLIENT_APP_ID=$(az ad app list --display-name "AzureOps MCP - VS Code Client" --query "[0].appId" -o tsv)
# Pre-authorize this client to call the server's access_as_user scope
$SCOPE_ID=$(az ad app show --id $SERVER_APP_ID --query "api.oauth2PermissionScopes[0].id" -o tsv)
az ad app update --id "$SERVER_APP_ID" --set api="{
'preAuthorizedApplications': [{
'appId': '$CLIENT_APP_ID',
'delegatedPermissionIds': ['$SCOPE_ID']
}]
}"Pre-authorization means VS Code can request a token without the user seeing a separate consent screen for your API — it was already granted when the client was registered.
Phase 2 — Configure Azure API Management as the MCP Gateway
az apim create \
--name azureops-apim \
--resource-group $RG \
--publisher-email you@contoso.com \
--publisher-name "Contoso AzureOps" \
--sku-name Developeraz apim create \
--name azureops-apim \
--resource-group $RG \
--publisher-email you@contoso.com \
--publisher-name "Contoso AzureOps" \
--sku-name DeveloperAPIM provisioning takes 30–45 minutes. While it runs, prepare the policy configuration.
Phase 3 — Lock Down the Container App Network
Before touching APIM, close the direct path. If the Container App's public URL still works without going through APIM, the auth layer above means nothing.
$RG="rg-azureops-mcp"
$ACA_APP="azureops-mcp-server"
# Get APIM's outbound IP addresses (after APIM is provisioned in Phase 2)
$APIM_IPS=$(az apim show --name azureops-apim \
--resource-group $RG --query "publicIPAddresses" -o tsv)
# Deny all traffic by default, then allow only APIM
az containerapp ingress access-restriction set \
--name $ACA_APP \
--resource-group $RG \
--rule-name "allow-apim-only" \
--ip-address "$APIM_IPS/32" \
--action Allow \
--rule-type IPRule
az containerapp ingress access-restriction set \
--name $ACA_APP \
--resource-group $RG \
--rule-name "deny-all-else" \
--ip-address "0.0.0.0/0" \
--action Deny \
--rule-type IPRule$RG="rg-azureops-mcp"
$ACA_APP="azureops-mcp-server"
# Get APIM's outbound IP addresses (after APIM is provisioned in Phase 2)
$APIM_IPS=$(az apim show --name azureops-apim \
--resource-group $RG --query "publicIPAddresses" -o tsv)
# Deny all traffic by default, then allow only APIM
az containerapp ingress access-restriction set \
--name $ACA_APP \
--resource-group $RG \
--rule-name "allow-apim-only" \
--ip-address "$APIM_IPS/32" \
--action Allow \
--rule-type IPRule
az containerapp ingress access-restriction set \
--name $ACA_APP \
--resource-group $RG \
--rule-name "deny-all-else" \
--ip-address "0.0.0.0/0" \
--action Deny \
--rule-type IPRuleFor production, prefer VNet integration with a private endpoint between APIM and the Container App over IP allow-listing — IP-based rules work, but VNet isolation removes the public attack surface entirely. IP rules are the faster path for this article; treat the VNet pattern as the next hardening step once this works end-to-end.
3.1 — Import the MCP Server as an APIM Backend API
In the Azure Portal: APIM → APIs → Add API → MCP Server. Point it at your Container App's /mcp endpoint. APIM creates the routing automatically — your existing endpoint structure doesn't change.
3.2 — Apply the Entra ID Validation Policy
On the imported MCP API, set this inbound policy:
<policies>
<inbound>
<validate-azure-ad-token tenant-id="{your-tenant-id}"
header-name="Authorization"
failed-validation-httpcode="401"
failed-validation-error-message="Unauthorized. Access token is missing or invalid.">
<client-application-ids>
<application-id>{server-app-id}</application-id>
</client-application-ids>
</validate-azure-ad-token>
<base />
</inbound>
<on-error>
<set-header name="WWW-Authenticate" exists-action="override">
<value>@{
return "Bearer resource_metadata=\"https://" + context.Request.OriginalUrl.Host
+ "/.well-known/oauth-protected-resource\"";
}</value>
</set-header>
<base />
</on-error>
</policies><policies>
<inbound>
<validate-azure-ad-token tenant-id="{your-tenant-id}"
header-name="Authorization"
failed-validation-httpcode="401"
failed-validation-error-message="Unauthorized. Access token is missing or invalid.">
<client-application-ids>
<application-id>{server-app-id}</application-id>
</client-application-ids>
</validate-azure-ad-token>
<base />
</inbound>
<on-error>
<set-header name="WWW-Authenticate" exists-action="override">
<value>@{
return "Bearer resource_metadata=\"https://" + context.Request.OriginalUrl.Host
+ "/.well-known/oauth-protected-resource\"";
}</value>
</set-header>
<base />
</on-error>
</policies>The on-error block matters as much as the validation itself. When a client calls without a token, MCP clients (VS Code included) expect a 401 with a WWW-Authenticate header pointing them to discovery metadata — without it, the client has no way to know how to authenticate, and the connection just fails silently from the user's perspective.
3.3 — Serve the Protected Resource Metadata Endpoint
RFC 9728 requires this exact path — it's not configurable. Add a new API operation in APIM at /.well-known/oauth-protected-resource with this policy:
<policies>
<inbound>
<return-response>
<set-status code="200" reason="OK" />
<set-header name="Content-Type" exists-action="override">
<value>application/json</value>
</set-header>
<set-body>@{
return new JObject(
new JProperty("resource", "https://" + context.Request.OriginalUrl.Host),
new JProperty("authorization_servers", new JArray(
"https://login.microsoftonline.com/{your-tenant-id}/v2.0"
))
).ToString();
}</set-body>
</return-response>
</inbound>
</policies><policies>
<inbound>
<return-response>
<set-status code="200" reason="OK" />
<set-header name="Content-Type" exists-action="override">
<value>application/json</value>
</set-header>
<set-body>@{
return new JObject(
new JProperty("resource", "https://" + context.Request.OriginalUrl.Host),
new JProperty("authorization_servers", new JArray(
"https://login.microsoftonline.com/{your-tenant-id}/v2.0"
))
).ToString();
}</set-body>
</return-response>
</inbound>
</policies>This is the document VS Code reads after receiving the 401 — it tells the client exactly which Entra tenant to authenticate against.
Phase 4 — Add Per-Tool Authorization in the Server
APIM validates that a caller has a valid token. It doesn't know your tools well enough to decide which tools the caller should reach — that decision belongs in your code, where the tool definitions live.
Update Program.cs to read and validate the forwarded token's claims:
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.IdentityModel.Tokens;
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
options.Authority = $"https://login.microsoftonline.com/{tenantId}/v2.0";
options.Audience = $"api://{serverAppId}";
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidateAudience = true
};
});
builder.Services.AddAuthorization(options =>
{
options.AddPolicy("OperationsRead", policy =>
policy.RequireClaim("roles", "AzureOps.Operations.Read"));
options.AddPolicy("FinOpsRead", policy =>
policy.RequireClaim("roles", "AzureOps.FinOps.Read"));
});using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.IdentityModel.Tokens;
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
options.Authority = $"https://login.microsoftonline.com/{tenantId}/v2.0";
options.Audience = $"api://{serverAppId}";
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidateAudience = true
};
});
builder.Services.AddAuthorization(options =>
{
options.AddPolicy("OperationsRead", policy =>
policy.RequireClaim("roles", "AzureOps.Operations.Read"));
options.AddPolicy("FinOpsRead", policy =>
policy.RequireClaim("roles", "AzureOps.FinOps.Read"));
});This is intentional defense-in-depth, not redundant work. APIM already rejected unauthenticated callers — this second check confirms the specific role required for the specific tool, directly in the code that owns the tool's sensitivity. If APIM is ever misconfigured or bypassed, this layer still holds.
Update each tool to check its required role before executing:
[McpServerTool(Name = "get_resource_tags")]
[Description("Fetches FinOps compliance tags. Requires AzureOps.FinOps.Read role.")]
public async Task<ResourceTagsResult> GetResourceTagsAsync(
[Description("Azure subscription ID")] string subscriptionId,
[Description("Resource group name")] string resourceGroup,
HttpContext httpContext)
{
if (!httpContext.User.HasClaim("roles", "AzureOps.FinOps.Read"))
{
throw new UnauthorizedAccessException(
"This tool requires the AzureOps.FinOps.Read role.");
}
// existing implementation unchanged
var subscription = _arm.GetSubscriptionResource(
SubscriptionResource.CreateResourceIdentifier(subscriptionId));
// ...
}[McpServerTool(Name = "get_resource_tags")]
[Description("Fetches FinOps compliance tags. Requires AzureOps.FinOps.Read role.")]
public async Task<ResourceTagsResult> GetResourceTagsAsync(
[Description("Azure subscription ID")] string subscriptionId,
[Description("Resource group name")] string resourceGroup,
HttpContext httpContext)
{
if (!httpContext.User.HasClaim("roles", "AzureOps.FinOps.Read"))
{
throw new UnauthorizedAccessException(
"This tool requires the AzureOps.FinOps.Read role.");
}
// existing implementation unchanged
var subscription = _arm.GetSubscriptionResource(
SubscriptionResource.CreateResourceIdentifier(subscriptionId));
// ...
}The HttpContext parameter is automatically populated by the MCP SDK from the current request — no manual wiring required. A caller with AzureOps.Operations.Read but not AzureOps.FinOps.Read will have get_deployment_status and check_resource_health work normally, and get_resource_tags throw a clear, role-specific error.
Phase 5 — Validate End-to-End
Update .vscode/mcp.json to point at the APIM endpoint instead of the Container App directly:
{
"servers": {
"azureops-mcp": {
"type": "http",
"url": "https://azureops-apim.azure-api.net/azureops-mcp/mcp"
}
}
}{
"servers": {
"azureops-mcp": {
"type": "http",
"url": "https://azureops-apim.azure-api.net/azureops-mcp/mcp"
}
}
}
Reload VS Code and open Copilot Chat in agent mode. The first connection attempt should trigger a sign-in prompt — VS Code received the 401 + WWW-Authenticate Header discovered the Entra tenant via the PRM document, and is now requesting a token.
Sign in with an account that has the AzureOps.Operations.Read role assigned. Run:
Check deployment status for subscription {sub-id}, resource group {rg}Check deployment status for subscription {sub-id}, resource group {rg}This should succeed — token validated by APIM, role checked by the server, tool executes.
Now try:
Check FinOps tag compliance for subscription {sub-id}, resource group {rg}Check FinOps tag compliance for subscription {sub-id}, resource group {rg}If your test account only has AzureOps.Operations.Read, this should fail with the role-specific error. Assign the AzureOps.FinOps.Read role and retry — it should now succeed.
Confirm the network lock independently — try calling the Container App's direct URL (not through APIM) with curl:
curl https://{container-app-fqdn}/healthcurl https://{container-app-fqdn}/healthThis should time out or be refused. If it responds, the access restriction from Phase 2 isn't applied correctly — go back and verify before considering this production-ready.
What This Doesn't Cover
Two things deliberately stayed out of scope here, and you should know why:
On-Behalf-Of (OBO) flow — if your tools needed to call Microsoft Graph or another downstream API as the signed-in user (reading their calendar, their SharePoint files), you'd need an OBO token exchange. Our tools only call Azure Resource Manager using the server's own Managed Identity, so this complexity isn't needed here. If your use case grows into per-user downstream calls, that's a deliberate architecture change, not a missing step.
Arbitrary third-party MCP clients — because Entra ID doesn't support Dynamic Client Registration, every client must be pre-registered, as we did for VS Code. If you need to support MCP clients you don't control, you need an OAuth proxy in front of Entra ID — a real added layer of complexity and risk that's outside this article's scope. For an internal enterprise tool used through Copilot and Foundry, pre-authorized clients are the right level of complexity, not a shortcut.
What's Next
The server is secured for a single caller's identity. Part 5 takes the next logical step: multiple users, each with their own data boundary. Right now, every authorized caller sees the same Azure subscription and resource groups. Part 5 adds per-user data isolation with Azure Cosmos DB — so a multi-tenant deployment of this server can serve different teams or customers without anyone seeing another's data.
Series Progress
- ✅ Part 1 — Mental model, why MCP matters for Azure
- ✅ Part 2 — First MCP server in C# .NET, tested in GitHub Copilot
- ✅ Part 3 — Deployed to Azure Container Apps with Managed Identity
- ✅ Part 4 ← You are here
- Part 5 — Multi-user data isolation with Cosmos DB
- Part 6 — Register in Microsoft Foundry Agent Service
- Part 7 — Observability with OpenTelemetry + Azure Monitor
- Part 8 — Full E2E production system + cost breakdown + production checklist
Divyesh writes production-grade Azure + Microsoft Foundry guides with real code and real benchmarks. Follow for Part 5 — multi-tenant data isolation done right.
Part 4 of MCP on Azure: From Zero to Production — published in [Microsoft Azure in Practice].