← All Minis/Identity Bridge
Security & Trust · Reference code

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.

Azure Functions MCP Entra ID · OBO PRM (RFC 9728) Python
USER signs in AI CLIENT VS Code · Copilot MCP SERVER validates · OBO AZURE your RBAC

🛡️ 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:

The sign-in → validate → act flow

01DiscoverClient reads the PRM doc and is pointed at Entra.
02Sign inUser authenticates; client gets a bearer token.
03CallClient calls /mcp with the user's token.
04ValidateServer checks JWKS signature, issuer, audience, scope.
05OBOServer swaps it for an ARM token via On-Behalf-Of.
06ActAzure responds within the user's own RBAC.

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

ToolWhat it returns
whoamiThe caller's validated identity claims.
list_subscriptionsAzure 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

/mcpMCP Streamable HTTP endpoint.
/.well-known/oauth-protected-resourceRFC 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."

☁️
Ask about your Azure"What subscriptions can I see?", "list the resource groups here", "what's deployed?" — answered within each person's RBAC.
🏛️
Regulated & federalPer-user attribution and least privilege are table stakes for compliance; no broad app role to over-grant.
🔁
Reusable auth patternSwap ARM for Microsoft Graph or an internal API — the PRM + Entra + OBO plumbing stays the same.
🧩
Zero-config client authVS Code / Copilot discover the auth server from PRM and sign in automatically — no per-client secrets.
🔎
Auditable AI actionsEvery call appears in Azure logs as the real user, not a shared service principal.
🚀
Production-shaped starterBicep infra, one-command 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.

gregunger-microsoft / mcp-prm-example-1 Python · PowerShell · Bicep · MCP PRM/OBO server on Azure Functions
Open repo

Clone the repo, create the Entra app, and deploy to Azure Functions (Flex Consumption) with azd.

clone & create the Entra app
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
deploy with azd
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

.vscode/mcp.json
{
  "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.
Deploy-time tip: zip-deploy uploads over the storage data plane, so the identity running 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.

src/server.py
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.

src/auth.py
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.

src/obo.py
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.
Heads up: Microsoft Entra does not support OAuth Dynamic Client Registration, so MCP clients use a pre-registered client app id and request 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.

identity-bridge.prompt.txt
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.
Tip: This is the same pattern the reference repo implements — use the prompt to learn it, then compare against the real code.

🐙 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