Identity Bridge
A reference MCP server on Azure Functions that lets an AI assistant act on Azure as the signed-in user — never with its own standing permissions. It wires together MCP over HTTP, standards-based OAuth 2.1 with Protected Resource Metadata (RFC 9728), Microsoft Entra ID, and the On-Behalf-Of flow.
🛡️ Per-user auth for AI tools
When you give an AI assistant the power to act on your systems through MCP tools, one question decides whether it's safe for the enterprise: whose permissions does it use? The easy-but-dangerous answer is a single shared service identity with broad rights — the classic "confused deputy" problem, where any user can make the tool do anything the tool can do.
Identity Bridge does it the right way. Every Azure call is made with the caller's own token. The server holds no standing Azure Resource Manager permissions, so a user can only ever see and do what their own Entra/RBAC role allows. Actions show up in Azure logs as the real person — auditable and least-privilege by construction.
It does this with three open standards stitched together correctly — usually the hard part:
- MCP over Streamable HTTP — so the server can be hosted remotely and shared by many users.
- OAuth 2.1 + Protected Resource Metadata (RFC 9728) — the server publishes
/.well-known/oauth-protected-resource, so MCP clients automatically discover Microsoft Entra ID and obtain a bearer token. No hand-configured client secrets on the client side. - On-Behalf-Of (OBO) — the server exchanges the caller's token for a downstream Azure Resource Manager token and reads resources scoped to that user's RBAC.
The sign-in → validate → act flow
/mcp with the user's token.🧰 Four read-only Azure tools
The repo ships a complete, deployable example with four MCP tools over Azure Resource Manager. The same pattern (PRM discovery + Entra bearer + OBO) can be pointed at any Entra-protected API — Microsoft Graph, an internal service — by swapping the downstream resource.
| Tool | What it returns |
|---|---|
whoami | The caller's validated identity claims. |
list_subscriptions | Azure subscriptions the user can access (via OBO). |
list_resource_groups(subscription_id) | Resource groups in a subscription. |
list_resources(subscription_id, resource_group?) | Resources in a subscription or group. |
Endpoints
/mcp | MCP Streamable HTTP endpoint. |
/.well-known/oauth-protected-resource | RFC 9728 Protected Resource Metadata. |
🎯 Use cases
"Let me ask Copilot about my Azure environment, and have it answer using only the access I personally have."
azd up to Flex Consumption, and an Entra app-creation script included.⚖️ Pros, cons & limitations
✓ Pros
- Per-user authorization — no shared, over-privileged identity.
- Zero-config client auth via open standards (PRM / RFC 9728).
- Least-privilege & auditable: calls run as the real user.
- Production-shaped: Bicep +
azd up+ Entra script. - Reusable pattern for any Entra-protected downstream API.
✕ Cons / trade-offs
- The client secret is a standing credential to protect.
- OBO requires a confidential client + correct scope wiring.
- Entra has no Dynamic Client Registration — clients use a pre-registered app id.
- More moving parts than a single-key, shared-identity tool.
△ Limitations
- Ships with four read-only ARM tools — a starting point, not a full product.
- Token version & issuer quirks (v1 vs v2) need handling — the repo does this.
- Deploy-time blob RBAC is required for zip-deploy (documented).
- Harden for production: move the secret to Key Vault.
📦 Get the code
The full project is on GitHub. Clone & deploy in a few commands, or read the key code to understand the auth dance. The repo is the source of truth.
Clone the repo, create the Entra app, and deploy to Azure Functions (Flex Consumption) with azd.
git clone https://github.com/gregunger-microsoft/mcp-prm-example-1.git cd mcp-prm-example-1 # 1. Create the Entra app registration (server + ARM OBO) az login ./scripts/create-entra-app.ps1 # Copy the printed TENANT_ID, CLIENT_ID, CLIENT_SECRET, REQUIRED_SCOPE. # If admin consent wasn't auto-granted, grant it in the portal: # App registrations -> (this app) -> API permissions -> Grant admin consent
azd auth login azd env new mcp-prm azd env set AZURE_SUBSCRIPTION_ID <sub-id> azd env set AZURE_LOCATION eastus azd env set TENANT_ID <tenant-guid> azd env set CLIENT_ID <app-client-id> azd env set CLIENT_SECRET <client-secret> azd env set REQUIRED_SCOPE access_as_user azd up # provisions infra, writes .env, deploys the function # verify curl https://<func-name>.azurewebsites.net/.well-known/oauth-protected-resource
Connect from VS Code
{
"servers": {
"mcp-prm-obo-azure": {
"type": "http",
"url": "https://<func-name>.azurewebsites.net/mcp"
}
}
}
// Start the server entry and sign in. VS Code reads the Protected Resource
// Metadata, requests api://<CLIENT_ID>/access_as_user from Entra, and attaches
// the bearer token to /mcp calls. To force a fresh sign-in, rename the server
// key (e.g. add a -v2 suffix) — VS Code caches tokens per server.
azd needs Storage Blob Data Owner on the Function's storage account (management-plane Owner is not enough). If azd deploy fails with 403 / BlobUploadFailedException, grant the role and retry.Three snippets capture the whole pattern. (Simplified from the repo's src/ for readability.)
1. Advertise Entra via PRM — server.py
FastMCP publishes Protected Resource Metadata so clients auto-discover the auth server. The scope must be fully-qualified (api://<CLIENT_ID>/access_as_user) or Entra resolves it against Microsoft Graph and blocks it.
from mcp.server.fastmcp import FastMCP
from mcp.server.auth.settings import AuthSettings
SCOPE = f"api://{CLIENT_ID}/access_as_user" # FULLY-QUALIFIED — required
mcp = FastMCP(
"mcp-prm-obo-azure",
auth=AuthSettings(
issuer_url=f"https://login.microsoftonline.com/{TENANT_ID}/v2.0",
resource_server_url=BASE_URL, # published in PRM (RFC 9728)
required_scopes=[SCOPE], # drives PRM + middleware check
),
token_verifier=EntraTokenVerifier(),
)
# Serves /.well-known/oauth-protected-resource and /mcp (Streamable HTTP).
2. Validate the bearer token — auth.py
Verify the JWT signature against Entra's JWKS, then the issuer, audience, and scope. Accept both v1 and v2 issuers — Entra may issue a v1 token (sts.windows.net) even with v2 configured.
import jwt
from jwt import PyJWKClient
_jwks = PyJWKClient(f"https://login.microsoftonline.com/{TENANT_ID}/discovery/v2.0/keys")
VALID_ISSUERS = {
f"https://login.microsoftonline.com/{TENANT_ID}/v2.0",
f"https://sts.windows.net/{TENANT_ID}/", # v1 issuer — also accepted
}
def verify(token: str) -> dict:
key = _jwks.get_signing_key_from_jwt(token).key
claims = jwt.decode(token, key, algorithms=["RS256"],
audience=f"api://{CLIENT_ID}") # no issuer= (checked below)
if claims["iss"] not in VALID_ISSUERS:
raise PermissionError("bad issuer")
if "access_as_user" not in claims.get("scp", "").split():
raise PermissionError("missing scope")
return claims
3. Exchange for an ARM token (OBO) — obo.py
Trade the caller's token for a downstream Azure Resource Manager token using MSAL's On-Behalf-Of flow. Every ARM call then runs as the signed-in user.
import msal
_app = msal.ConfidentialClientApplication(
CLIENT_ID, client_credential=CLIENT_SECRET,
authority=f"https://login.microsoftonline.com/{TENANT_ID}",
)
def arm_token_for_user(user_assertion: str) -> str:
result = _app.acquire_token_on_behalf_of(
user_assertion=user_assertion, # the caller's token
scopes=["https://management.azure.com/.default"], # downstream: ARM
)
if "access_token" not in result:
raise PermissionError(result.get("error_description", "OBO failed"))
return result["access_token"]
# Use this token for ARM REST calls -> the user's own RBAC applies.
api://<CLIENT_ID>/access_as_user. See the repo's troubleshooting table for AADSTS65002, 401 invalid_token, and 421 Invalid Host header fixes.Want to build a similar per-user MCP server from scratch? Paste this into GitHub Copilot Chat (agent mode) or any capable coding model.
You are a senior Azure identity engineer and an expert on the Model Context Protocol (MCP),
OAuth 2.1, Microsoft Entra ID, and the On-Behalf-Of (OBO) flow.
Build me a production-shaped, REMOTE, AUTHENTICATED MCP server (Python) that lets an AI
client call Azure AS THE SIGNED-IN USER — never with a standing service identity.
REQUIREMENTS
1. Transport & framework
- MCP over Streamable HTTP using FastMCP. Host on Azure Functions (Flex Consumption,
Linux, Python) via an ASGI entry point. routePrefix "" so /mcp and /.well-known/*
are at the root.
2. Standards-based auth (the core of this build)
- Act as an OAuth 2.1 Resource Server. Publish Protected Resource Metadata (RFC 9728)
at /.well-known/oauth-protected-resource pointing clients at Microsoft Entra ID.
- Advertise the FULLY-QUALIFIED scope api:///access_as_user (a bare scope
name makes Entra resolve against Microsoft Graph and fail).
- Validate incoming Entra bearer tokens: JWKS signature, issuer, audience, scope.
Accept BOTH v2 (login.microsoftonline.com/.../v2.0) and v1 (sts.windows.net/...)
issuers.
3. On-Behalf-Of
- Use MSAL ConfidentialClientApplication.acquire_token_on_behalf_of to exchange the
caller's token for an Azure Resource Manager token (scope
https://management.azure.com/.default), then call ARM REST as the user (their RBAC).
4. Tools
- whoami(), list_subscriptions(), list_resource_groups(subscription_id),
list_resources(subscription_id, resource_group?). Read-only. Return JSON.
5. Config & deploy
- Read ALL config from .env only (never os.environ): TENANT_ID, CLIENT_ID,
CLIENT_SECRET, REQUIRED_SCOPE, BASE_URL.
- Provide Bicep infra (storage, plan, Function App, App Insights, RBAC), an azd
azure.yaml with a post-provision hook that writes .env, and a PowerShell script
that creates the Entra app registration (sets requestedAccessTokenVersion = 2).
- Pass allowed_hosts/allowed_origins from BASE_URL to avoid 421 Invalid Host header
once deployed.
6. Docs
- README with the discover -> sign in -> call -> validate -> OBO -> act flow, a
.vscode/mcp.json example, and a troubleshooting table (AADSTS65002, 401
invalid_token, 421 Invalid Host, 403 BlobUploadFailedException).
Generate the full project and the exact az / azd commands, then tell me how to verify it
from VS Code (PRM discovery + sign-in). Ask me anything you need; otherwise build it.
🐙 Read the real code
Everything above is implemented end-to-end in the public repo — Bicep infra, the Entra app script, OBO, and the four ARM tools, with a documented troubleshooting guide.
View on GitHub