Frontier Transformation campaign in a box now available in Partner Marketing Center
June 24, 2026Azure Copilot Observability Agent is generally available, with autonomous operations in preview
June 24, 2026The Model Context Protocol exploded onto the scene because it’s easy. Stand up a server, expose a few tools, point Claude or VS Code at it, and your agent can suddenly read files, hit APIs, and run code. That same ease is the problem: most MCP servers ship with no authentication at all, and they’re getting pushed straight to the internet.
The numbers are bleak-into-an-incident-report bad. Astrix Research’s State of MCP Server Security 2025 found that only 8.5% of MCP servers use OAuth — the rest lean on static API keys or nothing. And the CVEs have already started:
- CVE-2025-6514 — a CVSS 9.6 OS command-injection flaw in
mcp-remote. If a client connects to a malicious or hijacked MCP server, the server can inject shell commands through the OAuthauthorization_endpointduring discovery and achieve remote code execution on the client. Roughly half a million downloads were exposed. - CVE-2025-49596 — RCE in the MCP Inspector dev tool, which shipped with no authentication on its local web UI. A crafted request from a webpage you happened to visit could execute code on your machine.
The throughline: MCP doesn’t enforce security at the protocol level. The spec is explicit that authorization is optional and implementation-dependent. That’s a reasonable design choice for a transport, but it means you own the perimeter. Skip it, and you’ve published an unauthenticated RPC endpoint that can read secrets and run tools.
So let’s not skip it. This post walks through a hardened MCP server on Azure App Service that closes every gap above — and most of it is platform configuration, not code you have to write and get right yourself.
Sample: seligj95/app-service-secure-mcp. One
azd up(plus an Entra app registration the hook creates for you) gives you an MCP server behind Easy Auth, talking to Key Vault over a managed identity, with no public network access, fronted by API Management, and an Application Insights alert watching for abuse.
The threat model for a hosted MCP server
Before the architecture, be honest about the attack surface. When an MCP server is internet-reachable, the bad days look like this:
- Unauthenticated tool invocation. Anyone who finds the endpoint calls your tools. If one of them reads a database or a secret, that’s the whole game.
- Credential exfiltration. A tool that returns a secret value — even “helpfully,” for debugging — hands credentials to whatever is driving the session.
- Prompt injection via tool responses. A compromised or malicious tool return can carry instructions that hijack the calling agent.
- Path traversal / injection. A tool that concatenates user input into a file path or shell command is the same class of bug we’ve fought for 25 years, now with an LLM cheerfully supplying the payload.
- Lateral movement. A server running with a broad identity or a network line of sight to everything becomes a pivot point.
The architecture below maps a defense to each one. None of it is exotic — it’s the App Service security stack, pointed at MCP.
The architecture
Five layers, each one a checkbox or a few lines of Bicep. Let’s take them in order.
1. Easy Auth — spec-compliant OAuth you don’t have to write
The single most important fix is also the easiest: turn on App Service built-in authentication (Easy Auth) and point it at Entra ID. Now App Service validates the token and rejects unauthenticated requests at the platform, before a single line of your Python runs.
App Service Authentication also has a built-in MCP server authorization mode (Preview) that makes your server comply with the MCP authorization spec: it serves Protected Resource Metadata (PRM) so a compliant MCP client can discover the authorization server and complete the OAuth handshake itself — instead of just getting a bare 401.
In the sample that’s an authsettingsV2 resource:
resource authSettings ‘Microsoft.Web/sites/config@2024-04-01’ = {
parent: web
name: ‘authsettingsV2’
properties: {
globalValidation: {
requireAuthentication: true
unauthenticatedClientAction: ‘Return401’ // reject, don’t redirect
}
identityProviders: {
azureActiveDirectory: {
enabled: true
registration: {
clientId: authClientId
openIdIssuer: ‘${environment().authentication.loginEndpoint}${authTenantId}/v2.0′
}
validation: {
allowedAudiences: [ ‘api://${authClientId}‘ ]
}
}
}
}
}
The piece that makes it MCP-compliant — not just “returns 401” — is enabling PRM. That’s one app setting that publishes the metadata document MCP clients look for:
{
name: ‘WEBSITE_AUTH_PRM_DEFAULT_WITH_SCOPES’
value: ‘api://${authClientId}/user_impersonation’
}
unauthenticatedClientAction: ‘Return401’ gives a clean 401 instead of a login redirect, and PRM turns that 401 into a discoverable OAuth challenge — the client follows the metadata, signs the user in, and retries with a valid token. Recall that 8.5% figure: this is the spec-compliant OAuth the other 91.5% are missing, and you got it from configuration, not code.
One gotcha worth calling out: when App Service creates the Entra registration for you, the default policy only accepts tokens the app itself obtained. For a real MCP client to connect, add its client id to the allowed-applications policy and preauthorize it on the app registration. (Entra has no Dynamic Client Registration, so the client ships a known client id; for VS Code / GitHub Copilot, preauthorization avoids a consent prompt the client won’t surface.)
The bonus is that the validated identity is handed to your code. App Service injects the caller’s claims into every forwarded request as the X-MS-CLIENT-PRINCIPAL headers — and crucially, it strips any client-supplied copy first, so they can’t be forged. The whoami tool just reads them:
def _client_principal(request: Request) –> Dict[str, Any]:
raw = request.headers.get(“x-ms-client-principal”)
# base64-encoded JSON of the caller’s claims, injected by Easy Auth
...
return {“authenticated”: bool(raw), “name”: name, ...}
Your tools now know who’s calling without you owning any of the token machinery.
2. Managed identity — stop storing the keys to the kingdom
The static-API-key habit is how secrets leak. Replace it with a system-assigned managed identity: App Service gets an Entra identity that Azure manages, and your code authenticates to Key Vault, Storage, or Azure OpenAI with no stored credential.
This matters for a subtle reason the MCP authorization guidance calls out explicitly: the token a client presents represents access to your server, not to Key Vault. Never forward it downstream — use the managed identity (or an on-behalf-of token) for that hop. Pass-through is a vulnerability; delegation is the fix, and the managed identity is how you delegate without holding a secret.
resource web ‘Microsoft.Web/sites@2024-04-01’ = {
identity: { type: ‘SystemAssigned’ }
…
}
In Python, DefaultAzureCredential resolves to that identity automatically — the same code runs locally against your az login and in Azure against the MI:
from azure.identity import DefaultAzureCredential
from azure.keyvault.secrets import SecretClient
credential = DefaultAzureCredential()
client = SecretClient(vault_url=KEY_VAULT_URI, credential=credential)
And least privilege is one role assignment. The sample grants the identity exactly Key Vault Secrets User — read secret values, nothing else:
var keyVaultSecretsUserRoleId = ‘4633458b-17de-408a-b874-0445c86b69e6’
resource appSecretsUser ‘Microsoft.Authorization/roleAssignments@2022-04-01’ = {
name: guid(keyVault.id, appPrincipalId, keyVaultSecretsUserRoleId)
scope: keyVault
properties: {
roleDefinitionId: subscriptionResourceId(‘Microsoft.Authorization/roleDefinitions’, keyVaultSecretsUserRoleId)
principalId: appPrincipalId
principalType: ‘ServicePrincipal’
}
}
There is now no secret used to read secrets. That’s the chain of custody you want.
3. Key Vault references — and a tool that won’t leak them
Two pieces here. First, Key Vault references keep secrets out of your configuration. You point an app setting at a vault URI, and App Service resolves the value at runtime via the managed identity:
{
name: ‘SECURE_CONFIG_VALUE’
value: ‘@Microsoft.KeyVault(SecretUri=${secureConfigSecretUri})’
}
The plaintext never appears in your repo, your Bicep, or the portal’s app settings blade — it shows up as a resolved reference.
Second, and this is the part developers get wrong: a tool that reads a secret should never return the secret. The sample’s read_secret_metadata proves the managed-identity path works end to end, then deliberately withholds the value:
async def tool_read_secret_metadata(secret_name: str = “demo-secret”):
secret = client.get_secret(secret_name)
return {
“available”: True,
“version”: secret.properties.version,
“value_length”: len(secret.value), # length, never the value
“note”: “Value intentionally withheld — metadata only.”,
}
If your MCP server has a get_secret tool that returns the secret, you’ve built a credential-exfiltration API with a friendly name. Return metadata; act on the value server-side.
The same discipline applies to input. The safe_lookup tool matches against a fixed allow-list and refuses anything that smells like traversal or injection — it never touches a filesystem or a shell:
suspicious = any(t in key for t in (“..”, “/”, “”, “;”, “|”, “$(“, “`”))
if key in DOCS:
return {“topic”: key, “doc”: DOCS[key], “found”: True}
return {“found”: False, “rejected_as_suspicious”: suspicious, ...}
safe_lookup(“../../etc/passwd”) comes back rejected_as_suspicious: true. That is the entire fix for a whole class of CVEs.
4. Private endpoints + APIM — take the server off the internet
Authentication is necessary but not sufficient. The strongest version of “don’t expose your MCP server” is to not expose it — give the App Service and Key Vault private endpoints, disable public network access, and let API Management be the only public door.
resource web ‘Microsoft.Web/sites@2024-04-01’ = {
properties: {
virtualNetworkSubnetId: appSubnetId // outbound: reach KV’s private endpoint
publicNetworkAccess: ‘Disabled’ // inbound: no public access at all
…
}
}
Now the App Service hostname returns nothing from the internet. The only ingress is the APIM gateway, which runs the security policy before traffic ever reaches the VNet — validate the Entra JWT, rate-limit per caller, and (the documented extension point) run a content-safety check:
<inbound>
<base />
<validate-jwt header-name=“Authorization“ failed-validation-httpcode=“401“>
<openid-config url=“https://login.microsoftonline.com/{tenant}/v2.0/.well-known/openid-configuration“ />
<required-claims>
<claim name=“aud“><value>api://{clientId}</value></claim>
</required-claims>
</validate-jwt>
<rate-limit-by-key calls=“60“ renewal-period=“60“
counter-key=“@(context.Request.IpAddress)“ />
<set-backend-service backend-id=“mcp-backend“ />
</inbound>
This is defense in depth: APIM validates the token, and Easy Auth validates it again at the app. An attacker has to get past a public gateway with JWT enforcement and rate limiting just to reach a private endpoint that also demands a valid token. Compare that to the median MCP server, which is a raw port on the internet.
The honest trade-off: this is a security reference architecture, not a 60-second demo. APIM takes ~30–45 minutes to provision, and because the app is private, you test through the gateway, not the App Service hostname. That friction is the point — it’s the same friction an attacker hits.
5. Monitoring — see the abuse before it’s an incident
The last layer is visibility. The Azure Monitor OpenTelemetry distro auto-instruments FastAPI, and the audit_event tool emits a structured custom event per call. A scheduled-query alert watches the rate of those events and fires when tool invocations spike — the signature of an agent looping over a sensitive tool, or someone probing the surface:
criteria: {
allOf: [{
query: ‘customEvents | where name == “mcp_tool_audit” | summarize calls = count() by bin(timestamp, 5m)’
metricMeasureColumn: ‘calls’
operator: ‘GreaterThan’
threshold: 100
}]
}
Tune the threshold to your baseline. The point is that “is someone hammering my credential-reading tool?” becomes an alert, not a forensic exercise after the fact.
Deploy it
azd auth login
azd up
A preprovision hook creates the Entra ID app registration and stashes its client id in the azd environment, so Easy Auth and the APIM policy wire themselves up. Then Bicep provisions the VNet, private endpoints, Key Vault, App Service, APIM, and the monitoring stack. (Grab a coffee for the APIM step.)
To verify, get a token and call through the gateway:
TOKEN=$(az account get-access-token
–resource “api://$(azd env get-value AZURE_AUTH_CLIENT_ID)“
–query accessToken -o tsv)
curl -s -X POST “$(azd env get-value APIM_MCP_URL)“
-H “Authorization: Bearer $TOKEN“ -H ‘content-type: application/json’
-d ‘{“jsonrpc”:”2.0″,”id”:1,”method”:”tools/call”,”params”:{“name”:”whoami”,”arguments”:{}}}’
The response shows the authenticated principal. Drop the token and you get a 401 from APIM — exactly what you want an unauthenticated caller to see.
To let a real MCP client (VS Code, Claude) sign users in itself rather than pasting a bearer token, point it at the same URL: PRM is already published, so the client discovers the auth server and runs the OAuth flow. Just make sure its app id is allowed — azd env set AZURE_MCP_CLIENT_APP_ID before azd up adds it to the allowed-applications policy — and preauthorize it on the server’s app registration so clients that don’t surface a consent prompt can connect.
Once it’s deployed and you’ve verified it, take the App Service off the public internet with a one-line flip — azd env set LOCK_DOWN_WEB_APP true && azd. (The first deploy keeps public access on just long enough to push the code, because a fully-private app can only be deployed from inside its VNet. The sample’s README walks through both phases.)
provision
Why this matters
MCP is going to be the USB-C of agent tooling, and right now most of the connectors are unauthenticated and exposed. The CVEs aren’t hypothetical — they have numbers and CVSS scores. But the fix isn’t a research project. On App Service, the perimeter is mostly configuration: flip on Easy Auth, use a managed identity, reference Key Vault, go private, front it with APIM, and alert on the logs.
That’s the difference between “I shipped an MCP server” and “I shipped an MCP server I’d put in production.” If you’re hosting MCP — especially anywhere a compliance auditor will eventually look — start from the secure shape, not the demo shape.
Try it
- Sample repo: github.com/seligj95/app-service-secure-mcp
- Astrix — State of MCP Server Security 2025: astrix.security
- MCP authorization spec: modelcontextprotocol.io
- App Service authentication: learn.microsoft.com