Secure MCP Server on Azure App Service
A reference implementation of a hardened MCP server on Azure App Service. It takes the "exposed MCP endpoint" problem seriously and closes it with the full App Service security stack:
- Easy Auth + MCP authorization (Preview) — authentication enforced at the
platform, before a request reaches your code, and made MCP-spec-compliant by hosting Protected Resource Metadata (PRM) so MCP clients can discover the auth server and complete the OAuth handshake. No OAuth flow to write.
- System-assigned managed identity — reads Key Vault secrets with no stored
credential.
- Key Vault + Key Vault references — secrets are injected at runtime, never
in config or source.
- VNet integration + private endpoints — the App Service and Key Vault have
no public network access. APIM is the only public ingress.
- API Management gateway — validates the Entra ID JWT, rate-limits, and
carries a content-safety extension point before forwarding over the VNet.
- Application Insights + anomaly alert — a scheduled-query alert fires when
tool-invocation volume spikes.
The MCP server itself uses stateless HTTP transport (MCP 2025-11-25), so it load-balances cleanly and every tool is a pure function of its arguments.
What's in the box
.
├── main.py # FastAPI MCP server (stateless HTTP) + secure tools
├── requirements.txt
├── azure.yaml # azd service def + Easy Auth preprovision hook
├── scripts/
│ ├── configure-easy-auth.sh # creates the Entra ID app registration (POSIX)
│ └── configure-easy-auth.ps1 # same, for Windows
├── infra/
│ ├── main.bicep # wires every module together
│ ├── main.parameters.json
│ ├── abbreviations.json
│ ├── app/
│ │ └── web.bicep # App Service: MI, VNet integ, private endpoint,
│ │ # Easy Auth, Key Vault references
│ └── shared/
│ ├── app-service-plan.bicep # P1v3 plan
│ ├── network.bicep # VNet, subnets, NSGs, private DNS zones
│ ├── keyvault.bicep # Key Vault + private endpoint + demo secrets
│ ├── keyvault-rbac.bicep # grants the app MI 'Key Vault Secrets User'
│ ├── monitoring.bicep # Log Analytics + App Insights + anomaly alert
│ └── apim.bicep # APIM + API + JWT/rate-limit/content-safety policy
├── static/style.css
└── templates/index.html # status page (shows principal + security posture)
MCP tools
| Tool | What it demonstrates | | --- | --- | | whoami | The Entra ID principal that Easy Auth validated, parsed from the platform-injected X-MS-CLIENT-PRINCIPAL headers — proof that auth is enforced | | get_config_status | Whether a Key Vault reference app setting resolved via managed identity (reports status only, never the value) | | read_secret_metadata | Fetches a Key Vault secret over managed identity and returns metadata only — the safe alternative to credential-leaking tools | | safe_lookup | Allow-list lookup that rejects path-traversal / injection payloads — safe tool-input handling | | audit_event | Emits an Application Insights custom event that feeds the anomaly alert |
Architecture
Local development
The server runs locally with no Azure dependencies — the Easy Auth and Key Vault paths degrade gracefully to "not configured".
python -m venv .venv
source .venv/bin/activate # Windows: .venv\Scripts\activate
pip install -r requirements.txt
python main.py
Open <http://localhost:8000/>. The MCP endpoint is http://localhost:8000/mcp. .vscode/mcp.json includes a secure-mcp-app-service-local server entry so VS Code can connect to it.
Try a tool over curl:
curl -s -X POST localhost:8000/mcp -H 'content-type: application/json' \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/call",
"params":{"name":"safe_lookup","arguments":{"topic":"../../etc/passwd"}}}'
# -> rejected_as_suspicious: true
Deploy to Azure
Heads up — this is a security reference architecture, not a 60-second demo. It provisions a VNet, private endpoints, Key Vault, and an API Management instance. APIM alone takes \~30–45 minutes to create. Budget for it.
azd auth login
azd up
What happens, in order:
- Preprovision hook (
scripts/configure-easy-auth.sh/.ps1) creates an
Entra ID app registration and stores its client id as AZURE_AUTH_CLIENT_ID in the azd environment. This id wires both Easy Auth and the APIM validate-jwt policy.
- Bicep provisions:
- VNet with delegated app-integration, private-endpoint, and APIM subnets +
private DNS zones.
- Log Analytics + Application Insights + the tool-anomaly alert rule.
- Key Vault (public access disabled) behind a private endpoint, seeded
with a demo-secret and a secure-config-value.
- P1v3 App Service with a system-assigned managed identity, regional VNet
integration, a private endpoint, Easy Auth (authsettingsV2) with MCP authorization PRM (WEBSITE_AUTH_PRM_DEFAULT_WITH_SCOPES), and a SECURE_CONFIG_VALUE Key Vault reference.
- A role assignment granting the app MI
Key Vault Secrets User. - API Management (External VNet mode) with an
mcpAPI, the JWT / rate-limit
/ content-safety policy, and the App Service as its backend.
- App deploy pushes the Python app via Oryx.
Outputs include APIM_MCP_URL (the public MCP endpoint) and WEB_URI (the App Service URL).
Lock down the App Service (the hardened end state)
A fully-private App Service can only receive a code push from inside the VNet, so the first azd up deploys with App Service public access enabled — that's the only way azd deploy (SCM/Kudu/Oryx) can reach it from your machine. Once the app is deployed and verified, flip it to APIM-only ingress:
azd env set LOCK_DOWN_WEB_APP true
azd provision
This re-runs Bicep and sets the App Service publicNetworkAccess: Disabled. From then on the only public surface is the APIM gateway, and any later azd deploy must run from a host with VNet/private-DNS access (self-hosted agent, jumpbox, or VPN). This two-phase pattern is the standard way to ship a private-ingress App Service.
Key Vault reference propagation. The
SECURE_CONFIG_VALUEreference resolves once the managed identity'sKey Vault Secrets Userrole assignment propagates (usually a few minutes). Until thenget_config_statusreports the value as not yet resolved; a single app restart forces an immediate refresh.
Deploy without auth (functional smoke test only)
To skip the app registration and Easy Auth entirely — handy to confirm the plumbing before layering auth on:
SKIP_EASY_AUTH=true azd up
Leaving Easy Auth off defeats the purpose of the sample; only do this to test.
Verify
Test through APIM — that's the path that exercises the full security stack (and the only path once you've locked the app down).
- Get an Entra ID access token for the API:
az account get-access-token \
--resource "api://$(azd env get-value AZURE_AUTH_CLIENT_ID)" \
--query accessToken -o tsv
- Call the MCP endpoint through the gateway:
APIM_MCP_URL=$(azd env get-value APIM_MCP_URL)
TOKEN=<token from step 1>
curl -s -X POST "$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's principal block shows the authenticated caller — proof that Easy Auth validated the token end to end.
- Confirm the gateway rejects unauthenticated calls:
curl -s -o /dev/null -w '%{http_code}\n' -X POST "$APIM_MCP_URL" \
-H 'content-type: application/json' \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'
# -> 401
- Watch the anomaly alert: drive a burst of
audit_eventcalls and inspect
Application Insights:
customEvents
| where name == "mcp_tool_audit"
| summarize calls = count() by bin(timestamp, 5m)
Connect VS Code to the deployed server
Edit .vscode/mcp.json and set the secure-mcp-app-service URL to your APIM_MCP_URL. VS Code will prompt for the Entra ID token (from az account get-access-token) and send it as a bearer header.
Letting a real MCP client sign in (MCP authorization)
The bearer-token approach above is fine for testing, but a spec-compliant MCP client (VS Code, Claude) signs the user in itself by discovering the server's Protected Resource Metadata. Two things make that work:
- PRM is published via the
WEBSITE_AUTH_PRM_DEFAULT_WITH_SCOPESapp setting
(wired automatically when Easy Auth is on). App Service answers the client's metadata probe with the scopes to request.
- The client must be allowed and preauthorized. Microsoft Entra ID has no
Dynamic Client Registration, so the client ships a known client id. Set it before azd up so it's added to the Easy Auth allowed-applications policy:
azd env set AZURE_MCP_CLIENT_APP_ID <mcp-client-app-id>
Also preauthorize that client id on the server's app registration (or have an admin consent), so clients like GitHub Copilot — which won't surface an interactive consent prompt — can connect without a consent error. For dev/test you can self-consent by visiting <APIM_MCP_URL host>/.auth/login/aad in a browser once.
MCP server authorization is currently a Preview App Service feature and gates access to the server, not to individual tools. Never forward the client's token to a downstream resource — use the managed identity (or an on-behalf-of token) for that hop, as the sample does for Key Vault.
Notes on the security choices
- APIM is the public surface. After the lockdown step
(LOCK_DOWN_WEB_APP=true), the App Service private endpoint plus publicNetworkAccess: Disabled mean the only way in is the APIM gateway, which enforces the JWT. This is defense in depth: APIM validates the token and Easy Auth validates it again at the app.
- Least privilege to Key Vault. The managed identity gets \`Key Vault Secrets
User\` (read secret values) — not list, set, or delete.
- Content safety is a documented hook. The APIM policy includes the place to
attach Azure AI Content Safety (or the APIM AI Gateway llm-content-safety policy). It's left as an extension point so azd up stays self-contained.
Optional: notes for restricted enterprise subscriptions
Most people deploying this on a normal Azure subscription can ignore this section — azd up just works. These two notes only apply if your subscription/tenant is governed by enterprise Azure Policy or security baselines (the kind of restrictions you'd find in a large corporate tenant). They're documented here only so the template degrades gracefully in those environments:
- APIM management NSG rule priority. Some enterprise baselines (e.g. Microsoft's
internal "NRMS") asynchronously inject Deny-Internet inbound rules around priorities 105–109. Because the ApiManagement control-plane IPs are public, a deny in that band can shadow the AllowApimManagement (3443) rule and cause API/policy imports to fail with \ManagementApiRequestFailed: Failed to connect to management endpoint …:3443. network.bicep\ therefore places that allow rule at priority 102 (below the typical deny band) so it's robust either way. If your environment additionally denies the APIM control plane at a layer above the subnet NSG, importing the API definition may still be blocked — that's a subscription-governance issue, not a problem with this template, and the App Service MCP server plus every other pillar are unaffected.
- Easy Auth app registration. In a restricted corporate tenant, \`az ad app
create\ can require a Service Tree ID. The preprovision hooks accept AZURE_SERVICE_MANAGEMENT_REFERENCE (env var or azd env set) and pass it as --service-management-reference. Set it before azd up, or deploy with SKIP_EASY_AUTH=true` to bring everything else up first. On a normal subscription neither of these is needed.
License
MIT.











