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 thefull App Service security stack:
- Easy Auth + MCP authorization (Preview) — authentication enforced at theplatform, before a request reaches your code, and made MCP-spec-compliant byhosting Protected Resource Metadata (PRM) so MCP clients can discover the authserver and complete the OAuth handshake. No OAuth flow to write.
- System-assigned managed identity — reads Key Vault secrets with no storedcredential.
- Key Vault + Key Vault references — secrets are injected at runtime, neverin config or source.
- VNet integration + private endpoints — the App Service and Key Vault haveno public network access. APIM is the only public ingress.
- API Management gateway — validates the Entra ID JWT, rate-limits, andcarries a content-safety extension point before forwarding over the VNet.
- Application Insights + anomaly alert — a scheduled-query alert fires whentool-invocation volume spikes.
The MCP server itself uses stateless HTTP transport (MCP 2025-11-25), soit 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 KeyVault 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 VSCode 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 Managementinstance. 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 anEntra ID app registration and stores its client id asAZURE_AUTH_CLIENT_IDin the azd environment. This id wires both Easy Authand the APIMvalidate-jwtpolicy. - 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, seededwith a
demo-secretand asecure-config-value. - P1v3 App Service with a system-assigned managed identity, regional VNetintegration, a private endpoint, Easy Auth (
authsettingsV2) with MCPauthorization PRM (WEBSITE_AUTH_PRM_DEFAULT_WITH_SCOPES), and aSECURE_CONFIG_VALUEKey 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 AppService 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'sthe only way azd deploy (SCM/Kudu/Oryx) can reach it from your machine. Oncethe 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. Fromthen on the only public surface is the APIM gateway, and any later azd deploymust run from a host with VNet/private-DNS access (self-hosted agent, jumpbox, orVPN). This two-phase pattern is the standard way to ship a private-ingress AppService.
Key Vault reference propagation. The
SECURE_CONFIG_VALUEreferenceresolves once the managed identity'sKey Vault Secrets Userrole assignmentpropagates (usually a few minutes). Until thenget_config_statusreports thevalue 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 theplumbing 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 thatEasy 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 inspectApplication 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 yourAPIM_MCP_URL. VS Code will prompt for the Entra ID token (fromaz 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 MCPclient (VS Code, Claude) signs the user in itself by discovering the server'sProtected 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'smetadata probe with the scopes to request. - The client must be allowed and preauthorized. Microsoft Entra ID has noDynamic Client Registration, so the client ships a known client id. Set itbefore
azd upso 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 havean admin consent), so clients like GitHub Copilot — which won't surface aninteractive consent prompt — can connect without a consent error. For dev/testyou can self-consent by visiting <APIM_MCP_URL host>/.auth/login/aad in abrowser once.
MCP server authorization is currently a Preview App Service feature andgates access to the server, not to individual tools. Never forward theclient's token to a downstream resource — use the managed identity (or anon-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 pluspublicNetworkAccess: Disabledmean the only way in is the APIM gateway, whichenforces the JWT. This is defense in depth: APIM validates the token andEasy Auth validates it again at the app. - Least privilege to Key Vault. The managed identity gets `Key Vault SecretsUser` (read secret values) — not list, set, or delete.
- Content safety is a documented hook. The APIM policy includes the place toattach Azure AI Content Safety (or the APIM AI Gateway
llm-content-safetypolicy). It's left as an extension point soazd upstays 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 isgoverned by enterprise Azure Policy or security baselines (the kind of restrictionsyou'd find in a large corporate tenant). They're documented here only so thetemplate degrades gracefully in those environments:
- APIM management NSG rule priority. Some enterprise baselines (e.g. Microsoft'sinternal "NRMS") asynchronously inject
Deny-Internet inbound rules aroundpriorities 105–109. Because theApiManagementcontrol-plane IPs are public, adeny in that band can shadow theAllowApimManagement(3443) rule and causeAPI/policy imports to fail with `ManagementApiRequestFailed: Failed to connect tomanagement endpoint …:3443.network.bicep` therefore places that allow rule atpriority 102 (below the typical deny band) so it's robust either way. If yourenvironment additionally denies the APIM control plane at a layer above thesubnet NSG, importing the API definition may still be blocked — that's asubscription-governance issue, not a problem with this template, and the AppService MCP server plus every other pillar are unaffected. - Easy Auth app registration. In a restricted corporate tenant, `az ad appcreate` can require a Service Tree ID. The preprovision hooks accept
AZURE_SERVICE_MANAGEMENT_REFERENCE(env var orazd env set) and pass it as--service-management-reference. Set it beforeazd up, or deploy withSKIP_EASY_AUTH=trueto bring everything else up first. On a normal subscriptionneither of these is needed.
License
MIT.