Skip to main content
Glama
seligj95

Secure MCP Server on Azure App Service

by seligj95

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)

Related MCP server: mcp-secure

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

Architecture: an MCP client gets a token from Entra ID, then calls API Management over HTTPS. APIM is the only public ingress and runs validate-jwt, rate-limiting, and a content-safety hook before forwarding over the VNet to an App Service that enforces Easy Auth. The App Service uses a system-assigned managed identity, regional VNet integration, a private endpoint, and Key Vault reference app settings to reach a Key Vault that has a private endpoint, RBAC, and no public access. The App Service also emits telemetry to Application Insights + Log Analytics, which runs a scheduled-query alert on tool-call spikes.

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:

  1. 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.

  2. 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 mcp API, the JWT / rate-limit / content-safety policy, and the App Service as its backend.

  3. 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_VALUE reference resolves once the managed identity's Key Vault Secrets User role assignment propagates (usually a few minutes). Until then get_config_status reports 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).

  1. 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
  1. 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.

  1. 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
  1. Watch the anomaly alert: drive a burst of audit_event calls 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_SCOPES app 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.

Related MCP Connectors

Related MCP Servers

  • A
    license
    A
    quality
    B
    maintenance
    MCP server for secure, just-in-time secret retrieval from 1claw vault and malicious content inspection, enabling AI agents to access secrets and security analysis.
    1
    164 npm
    2
    MIT
  • A
    license
    Not graded
    quality
    C
    maintenance
    A secure-by-default framework for building MCP servers on Azure Entra ID, enforcing OWASP API Security Top 10 controls with authentication, authorization, and rate limiting.
    MIT
  • F
    license
    Not graded
    quality
    B
    maintenance
    A governed MCP server with OAuth 2.1 + PKCE, declarative tool scoping, row-level data filters, per-identity rate limits, and a tamper-evident audit trail.
    -
  • A
    license
    A
    quality
    C
    maintenance
    A reference MCP server that enforces identity-aware tool access using role-based permissions (Reader, Analyst, Auditor) and supports multiple identity routes like Entra RBAC, Copilot Studio, and Okta XAA. It provides read-only access to test evidence with explicit denial paths for unauthorized tools.
    7
    MIT