Introduction: The Problem
When a customer approached us with a seemingly straightforward requestโ”We need an MCP server that’s secure and fast”โwe didn’t anticipate the journey ahead. The Model Context Protocol (MCP) had been gaining traction as a way to expose tools and resources to AI agents, but one critical piece was still evolving: authorization.
Why an MCP Server?
The customer was building a voice-enabled bot that could answer user questions about enterprise data. Users needed to stream queries and retrieve summaries from documents stored in SharePoint. An MCP server was the natural fitโit provides a standardized way to expose tools and resources to AI agents, allowing the voice bot to seamlessly invoke document retrieval and summarization capabilities.
While the specific SharePoint integration involved its own tooling for document access and retrieval, we’re not diving into those details here. Instead, we’re focusing on the authentication and authorization patterns that apply across environments: token validation, OAuth 2.1 compliance, and On-Behalf-Of (OBO) flows for accessing downstream APIs on the user’s behalf.
A note on external resource security: The SharePoint data accessed by this MCP server was retrieved through a separate service with scoped access to a controlled workspace. Document access was validated by verifying the user’s Object ID (OID) against document ACLs, and the workspace access was controlled and audited by the customer. This layered approachโcombining OAuth-based user authentication with document-level ACL enforcementโhelped mitigate risks like indirect prompt injection by ensuring users could only access documents they were authorized to view.
The Requirements
The customer’s requirements were clear:
- Enterprise-grade security: Azure AD (Entra ID) integration with proper token validation
- Low latency: No per-request round trips to identity providers
- Downstream API access: The ability to call Microsoft Graph on behalf of authenticated users
- Standards compliance: Following OAuth 2.1 best practices
The challenge? The MCP specification had been changing rapidly to address security concerns. Earlier versions had limited guidance on authorization, and the community was still figuring out best practices. We needed to build something production-ready while the ground was shifting beneath our feet.
Scope of this post: We’ll focus specifically on the MCP authorization codeโtoken validation, On-Behalf-Of flows, and OAuth 2.1 integration. We won’t cover infrastructure concerns like Azure Container Apps with private endpoints, observability, redundancy, or networking. Those are important topics, but they deserve their own dedicated treatment.
The patterns shown here work well for synchronous tool callsโshort-running operations where the client waits for a response. Long-running agentic workflows with streaming results may require different transport considerations.
Why We Chose Azure Container Apps
Before diving into the code, it’s worth explaining our infrastructure choice. We evaluated three architectural options:
| Option | Description | Trade-offs |
|---|---|---|
| Azure Container Apps (chosen) | MCP server handles authorization internally | Lowest latency, simplest ops, full control |
| APIM + Azure Functions | API Management validates JWT, Functions execute tools | Policy-based auth, but ~20-50ms overhead |
| APIM + Container Apps | APIM as gateway with native MCP server support | Centralized governance, ~15-30ms overhead |
| Azure App Service | PaaS with built-in MCP authorization (preview) | Low latency (~5-15ms), no scale-to-zero |
Given our customer’s requirement for sub-second response times with no cold starts, we chose Azure Container Apps with internal authorization. Here’s why:
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ Azure Container Apps โ
โ โ
โ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ โ
โ โ MCP Server โ โ
โ โ โ โ
โ โ โโโโโโโโโโโโโโโ โโโโโโโโโโโโโโโ โโโโโโโโโโโโโโโโโโโ โ โ
โ โ โ Token โ โ MCP Tools โ โ OBO Flow โ โ โ
โ โ โ Verifier โ โ (FastMCP) โ โ (Graph API) โ โ โ
โ โ โ (JWKS) โ โ โ โ โ โ โ
โ โ โโโโโโโโฌโโโโโโโ โโโโโโโโโโโโโโโ โโโโโโโโโโฌโโโโโโโโโ โ โ
โ โ โ โ โ โ
โ โโโโโโโโโโโผโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโผโโโโโโโโโโโโโ โ
โ โ โ โ
โ โโโโโโโโโโโดโโโโโโโโโโ โ โ
โ โ Managed Identity โโโโโโโโโโโโโโโโโโโโโโโโโโโโ โ
โ โ (Secretless Auth) โ โ
โ โโโโโโโโโโโโโโโโโโโโโ โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ โ
โผ โผ
โโโโโโโโโโโโโโโโโโโโ โโโโโโโโโโโโโโโโโโโโ
โ Azure AD โ โ Microsoft Graph โ
โ (Entra ID) โ โ API โ
โ โ โ โ
โ - JWKS Endpoint โ โ - User Profile โ
โ - Token Issuer โ โ - OBO Exchange โ
โโโโโโโโโโโโโโโโโโโโ โโโโโโโโโโโโโโโโโโโโ
Key advantages of this approach:
- Single hop: Requests go directly to the applicationโno API gateway in the middle adding latency
- JWKS caching: Token validation drops to sub-millisecond times after initial key fetch
- Always warm: Setting
minReplicas=1eliminates cold starts (~$30-50/month) - Self-contained: The container image includes everything, making it portable to any environment
- Secretless in production: Federated credentials eliminate secret management entirely
When might you choose differently?
- If you need centralized API governance across multiple MCP servers, consider APIMโthough note that APIM can be relatively expensive (~$175/month for Basic tier), so cost-conscious teams or startups may prefer the Container Apps approach
- If you want declarative, policy-based authorization, APIM’s
validate-jwtpolicy is compelling - If you’re already heavily invested in Azure Functions, the APIM + Functions pattern may integrate better
- If you prefer platform-managed authorization and need deployment slots for blue/green deployments, Azure App Service with its built-in MCP authorization (preview) is worth consideringโthough it lacks scale-to-zero and costs ~2-4x more
Why not APIM for rate limiting? While APIM provides built-in rate limiting capabilities, this wasn’t a driving concern for our use case. The MCP server was designed for internal consumption by a small set of trusted users. Our primary requirements were streaming support and low latencyโadding APIM as a gateway would have introduced unnecessary overhead (~15-50ms per request) without meaningful benefit for our scenario.
The Journey: Our Approach and Solution
We decided to follow the MCP Authorization Specification (2025-11-25), which finally provided clear guidance on OAuth 2.1 integration. Our goal was to create a reusable implementation that could serve as a reference for others facing the same challenges.
Section 1: Understanding the MCP Auth Landscape
The MCP specification defines two key roles:
- Authorization Server: Issues tokens (Azure AD in our case)
- Resource Server (MCP Server): Validates tokens and serves protected resources
The spec mandates support for RFC 9728 – OAuth 2.0 Protected Resource Metadata, which allows clients to discover where to authenticate. This was crucial for VS Code and other MCP clients that needed to know how to obtain tokens.
Important: The
/.well-known/oauth-protected-resourceendpoint path is not configurableโit must be exactly this path as defined by RFC 9728. MCP clients will look for this specific endpoint to discover authorization requirements.
Let’s break down each step of this flow, starting with how we optimized token verification.
โโโโโโโโโโโโโโโ โโโโโโโโโโโโโโโโโโโ โโโโโโโโโโโโโโโโ
โ MCP Client โโโโโ>โ MCP Server โโโโโ>โ Graph API โ
โ (VS Code) โ โ (Resource) โ โ (Downstream)โ
โโโโโโโโโโโโโโโ โโโโโโโโโโโโโโโโโโโ โโโโโโโโโโโโโโโโ
โ โ โ
โ 1. Discover auth โ โ
โ server โ โ
โโโโโโโโโโโโโโโโโโโโโโ โ
โ โ โ
โ 2. Get token from โ โ
โ Azure AD โ โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ>
โ โ โ
โ 3. Call with token โ โ
โโโโโโโโโโโโโโโโโโโโ>โ โ
โ โ 4. OBO exchange โ
โ โโโโโโโโโโโโโโโโโโโโโโโ>โ
โ โ โ
โ 5. Response โ โ
โโโโโโโโโโโโโโโโโโโโโโ โ
Section 2: Token Verification with JWKS Caching
The first challenge was validating Azure AD tokens without adding latency to every request. Azure AD uses asymmetric keys (RS256), and the public keys are available via a JWKS (JSON Web Key Set) endpoint.
The naive approachโfetching JWKS on every requestโwould add 50-100ms of latency. Instead, we implemented a caching strategy:
import aiohttp
# ... later in the code ...
class EntraIdTokenVerifier(TokenVerifier):
"""JWT token verifier with 1-hour JWKS caching."""
def __init__(self, tenant_id: str, client_id: str):
self.jwks_uri = f"https://login.microsoftonline.com/{tenant_id}/discovery/v2.0/keys"
self.issuer = f"https://login.microsoftonline.com/{tenant_id}/v2.0"
self._jwks_cache: Optional[Dict] = None
self._cache_expiry: Optional[datetime] = None
async def _get_jwks(self) -> Dict[str, Any]:
now = datetime.now(timezone.utc)
# Return cached if still valid
if self._jwks_cache and self._cache_expiry and now < self._cache_expiry:
return self._jwks_cache
# Fetch fresh JWKS
async with aiohttp.ClientSession() as session:
async with session.get(self.jwks_uri) as response:
self._jwks_cache = await response.json()
self._cache_expiry = now + timedelta(hours=1)
return self._jwks_cache
This reduced our token validation to sub-millisecond times for cached keys while still refreshing them hourly to handle key rotation.
Caching strategy: We chose a 1-hour cache duration as a balance between performance and security. This is long enough to dramatically reduce latency (avoiding round-trips to the JWKS endpoint) but short enough to pick up key rotations in a reasonable timeframe. Azure AD typically rotates signing keys every few weeks, so a 1-hour cache is well within safe bounds. In production, consider making this value configurable to tune for your specific requirements.
Section 3: The On-Behalf-Of Flow Challenge
Our customer needed to call Microsoft Graph to fetch user profiles. The problem: we had the user’s token for our MCP server, but we needed a Graph API token.
Enter the OAuth 2.0 On-Behalf-Of (OBO) flowโthe server trades the user’s token for a new token scoped to a downstream API, allowing it to call that API on the user’s behalf. OBO is also a practical option for async or autonomous agents that need to access content on a user’s behalf, beyond just MCP requests.
async def get_graph_token_obo(assertion_token: str) -> str:
"""Exchange user token for Graph API token using OBO flow."""
# Validate the incoming token isn't about to expire
_validate_assertion_token_expiry(assertion_token)
token_endpoint = f"https://login.microsoftonline.com/{tenant_id}/oauth2/v2.0/token"
data = {
"client_id": client_id,
"client_secret": client_secret,
"grant_type": "urn:ietf:params:oauth:grant-type:jwt-bearer",
"requested_token_use": "on_behalf_of",
"scope": "https://graph.microsoft.com/User.Read",
"assertion": assertion_token,
}
async with aiohttp.ClientSession() as session:
async with session.post(token_endpoint, data=data) as resp:
body = await resp.json()
return body["access_token"]
A critical lesson learned: always validate the incoming token’s expiry before OBO exchange. We added a 5-minute buffer to prevent race conditions where the token expires mid-exchange:
def _validate_assertion_token_expiry(assertion_token: str) -> None:
payload = jwt.decode(assertion_token, options={"verify_signature": False})
exp = payload.get("exp")
if exp:
time_until_expiry = exp - time.time()
if time_until_expiry < 300: # 5 minutes
raise RuntimeError("Token expiring soon, please re-authenticate")
Section 4: RFC 9728 Protected Resource Metadata
For MCP clients to know where to authenticate, we needed to implement the OAuth Protected Resource Metadata endpoint:
@mcp_server.custom_route("/.well-known/oauth-protected-resource", methods=["GET"])
async def oauth_protected_resource_metadata(request: Request) -> JSONResponse:
return JSONResponse({
"resource": "http://localhost:9000",
"bearer_methods_supported": ["header"],
"authorization_servers": [
f"https://login.microsoftonline.com/{tenant_id}/v2.0"
],
"scopes_supported": [
f"api://{client_id}/access_as_user",
f"api://{client_id}/MCP.Tools",
]
})
This endpoint tells clients: “I’m a protected resource, and here’s where you get tokens to access me.”
Note on scopes: The scopes shown above (
access_as_user,MCP.Tools) are examples. Scopes control what MCP clients are authorized to doโyou might defineMCP.Tools.Readfor read-only access orMCP.Tools.Writefor operations that modify data. In Azure AD, scope names follow the formatapi://{client-id}/{scope-name}. Theapi://prefix identifies it as a custom API scope, the{client-id}is your App Registration’s Application (Client) ID, and the scope name is defined in your App Registration’s “Expose an API” section. Your MCP server validates these scopes in the token to enforce what each client can access.
Section 5: Secretless Production Deployment
Client secrets are fine for development, but production deployments should avoid secrets entirely. We implemented support for Federated Identity Credentials, which use Azure Managed Identities for Container Apps. To set this up, you’ll need to configure your App Registration to trust a managed identity by adding a federated credential in the “Certificates & secrets” section:
async def _get_graph_token_with_federated_credential(assertion_token: str) -> str:
# Use managed identity token as client assertion
mi_credential = ManagedIdentityCredential(client_id=managed_identity_oid)
mi_token = await mi_credential.get_token("api://AzureADTokenExchange/.default")
def get_client_assertion() -> str:
return mi_token.token
obo_credential = OnBehalfOfCredential(
tenant_id=tenant_id,
client_id=client_id,
client_assertion_func=get_client_assertion,
user_assertion=assertion_token,
)
graph_token = await obo_credential.get_token("https://graph.microsoft.com/User.Read")
return graph_token.token
This eliminates client secrets from the deployment entirelyโthe managed identity authenticates using Azure’s Instance Metadata Service (IMDS).
The Destination: Outcomes and Learnings
After several iterations, we delivered a production-ready MCP server that met all requirements:
| Metric | Result |
|---|---|
| Token validation latency | < 1ms (cached JWKS) |
| OBO exchange latency | ~100-200ms (Azure AD round trip) |
| Standards compliance | OAuth 2.1, RFC 9728, RFC 8414 |
Key Takeaways
- Cache JWKS aggressively: A 1-hour cache dramatically reduces latency while still handling key rotation.
- Validate token expiry before OBO: A 5-minute buffer prevents frustrating “token expired during exchange” errors.
- Plan for secretless deployments: Federated Identity Credentials should be the production default.
- Implement Protected Resource Metadata: MCP clients need
/.well-known/oauth-protected-resourceto discover auth requirements. - Handle scope extraction carefully: Azure AD tokens may have scopes in
scp(space-separated string) orroles(array)โhandle both.
Common Pitfalls & Things to Watch For
During our implementation and deployment, we encountered several configuration issues that caused authorization failures. Here’s what to watch for.
A word of caution: It’s surprisingly easy to get OAuth configuration wrong, and the resulting errors are often cryptic or misleading. Azure AD error messages don’t always point directly to the root cause. We hope this section helps clarify potential issues and saves you some debugging time.
Here’s a sample .env configuration showing all the required settings. Pay close attention to the different IDsโmixing up TENANT_ID, CLIENT_ID, and FEDERATED_CREDENTIAL_OID is one of the most common mistakes:
# =============================================================================
# Server Settings
# =============================================================================
HOST=127.0.0.1
PORT=9000
DEBUG=false
SERVER_NAME=DemoMcpServer
# =============================================================================
# Authentication Settings
# =============================================================================
# Azure AD (Entra ID) tenant ID
# โ ๏ธ This is your DIRECTORY ID, not your app's ID!
# Get this from: Azure Portal > App Registrations > Your App > Overview
TENANT_ID=16b3c013-d300-468d-xxxx-xxxxxxxxxxxx
# Application (client) ID
# โ ๏ธ This is your APP's ID, not the tenant!
# Get this from: Azure Portal > App Registrations > Your App > Overview
CLIENT_ID=58348f96-645d-4a2e-xxxx-xxxxxxxxxxxx
# Expected audience (typically same as CLIENT_ID)
AUDIENCE=58348f96-645d-4a2e-xxxx-xxxxxxxxxxxx
# =============================================================================
# OBO (On-Behalf-Of) Flow - Choose ONE option
# =============================================================================
# Option 1: Client Secret (works locally and in Azure)
CLIENT_SECRET=your-client-secret-here
# Option 2: Federated Identity Credential (Azure-only, secretless)
# โ ๏ธ This is the MANAGED IDENTITY's Principal ID, not the app's Client ID!
# Get this from your deployment output or:
# Azure Portal > Managed Identities > Your Identity > Overview > Object (Principal) ID
# FEDERATED_CREDENTIAL_OID=a1b2c3d4-e5f6-7890-xxxx-xxxxxxxxxxxx
# =============================================================================
# OAuth 2.1 / RFC 9728 Settings
# =============================================================================
# โ ๏ธ Must match your actual deployment URL, not localhost!
RESOURCE_SERVER_URL=https://your-app.azurecontainerapps.io
Common ID confusion: Azure uses multiple GUIDs that look similar but serve different purposes. The
TENANT_IDidentifies your Azure AD directory. TheCLIENT_IDidentifies your specific app registration. TheFEDERATED_CREDENTIAL_OIDis the Object ID of a managed identityโa completely different resource. Swapping any of these will cause authentication failures with unhelpful error messages.
1. Client ID vs Tenant ID Confusion
The Problem: Azure AD requires multiple GUIDs (Tenant ID, Client ID, Object ID), and it’s easy to mix them up. We accidentally used the Tenant ID where the Client ID was expected, causing all API scope URIs to be wrong.
What Goes Wrong: The OAuth metadata returns scopes like api://{tenant-id}/access_as_user instead of api://{client-id}/access_as_user. Clients then request the wrong scopes, and Azure AD rejects the token request.
How to Check: Verify your /.well-known/oauth-protected-resource endpoint returns scopes with your Application (Client) ID, not your Tenant ID:
curl https://your-server/.well-known/oauth-protected-resource | jq .scopes_supported
# Should show: "api://YOUR-CLIENT-ID/access_as_user"
# NOT: "api://YOUR-TENANT-ID/access_as_user"
The Fix: Double-check your environment variables:
TENANT_ID: Your Azure AD directory ID (e.g.,16b3c013-d300-...)CLIENT_ID: Your App Registration’s Application ID (e.g.,58348f96-645d-...)
These are different values! Find them in the Azure Portal under App Registrations > Your App > Overview.
2. Resource Server URL Showing Localhost in Production
The Problem: The OAuth Protected Resource Metadata returns "resource": "http://localhost:9000" even when deployed to the cloud. This happens because the server doesn’t know its external URL.
What Goes Wrong: MCP clients use this resource field to identify which token to use. If it shows localhost but you’re accessing https://your-cloud-app.azurecontainerapps.io, the client may fail to match tokens correctly.
How to Check:
curl https://your-cloud-server/.well-known/oauth-protected-resource | jq .resource
# Should show: "https://your-cloud-server"
# NOT: "http://localhost:9000"
The Fix: Set the RESOURCE_SERVER_URL environment variable to your cloud URL:
# For Azure Container Apps with azd
azd env set RESOURCE_SERVER_URL "https://your-app.azurecontainerapps.io"
azd provision
Note: This requires a two-phase deploymentโfirst deploy to get the URL, then set the variable and redeploy.
3. Pre-authorized Client Applications
The Problem: When using VS Code or other MCP clients, the OAuth flow may prompt users to enter a Client ID, rather than using a known client automatically.
What Goes Wrong: Your App Registration doesn’t have the client application pre-authorized, so Azure AD treats it as an unknown third-party app.
The Fix: In Azure Portal, go to App Registrations > Your App > Expose an API > Add a client application. Add the client IDs for known MCP clients:
- VS Code: Check the MCP extension documentation for its Client ID
- Add your own client applications as needed
4. Caching Causing Stale Responses
The Problem: After fixing configuration issues and redeploying, the OAuth metadata endpoints still return old values.
What Goes Wrong: Azure Container Apps (and CDNs) may cache responses. Our endpoints return Cache-Control: public, max-age=3600, which is good for production but frustrating during debugging.
How to Check: Use cache-busting headers:
curl -H "Cache-Control: no-cache" -H "Pragma: no-cache" \
https://your-server/.well-known/oauth-protected-resource | jq .
The Fix: Wait for cache expiry, or force a new container revision deployment. In Azure Container Apps:
az containerapp revision list --name your-app --resource-group your-rg
# Check if latest revision is running
5. Federated Credential Configuration
The Problem: Secretless OBO flow fails with “Invalid client assertion” or similar errors.
What Goes Wrong: The federated credential linking your Managed Identity to your App Registration is misconfigured.
The Fix: Ensure the federated credential is set up correctly:
- Get your Managed Identity’s Principal ID from the deployment output
- In Azure Portal, go to App Registrations > Your App > Certificates & secrets > Federated credentials
- Add a credential with:
- Issuer:
https://login.microsoftonline.com/{tenant-id}/v2.0 - Subject Identifier: The Managed Identity’s Principal ID
- Audience:
api://AzureADTokenExchange
- Issuer:
Quick Validation Checklist
Before going live, verify these endpoints return correct values:
# 1. Check resource URL matches your deployment
curl https://your-server/.well-known/oauth-protected-resource | jq '.resource'
# 2. Check scopes use your Client ID (not Tenant ID)
curl https://your-server/.well-known/oauth-protected-resource | jq '.scopes_supported[0]'
# 3. Check authorization server points to your tenant
curl https://your-server/.well-known/oauth-protected-resource | jq '.authorization_servers[0]'
# 4. Health check works
curl https://your-server/health
Conclusion
Building a secure MCP server in a rapidly evolving specification landscape was challenging, but the resulting implementation follows best practices that should remain stable. The key insight is that MCP authentication is fundamentally OAuth 2.1 with Protected Resource Metadataโonce you understand that, the pieces fall into place.
The MCP ecosystem is growing rapidly, and we believe secure-by-default implementations will be crucial for enterprise adoption. We hope this work helps accelerate that journey.
References
Architecture & Infrastructure
- Azure Container Apps Overview
- Reducing Cold-Start Time on Azure Container Apps
- Azure API Management MCP Server Support
- Host Remote MCP Servers in Azure App Service
- Configure MCP Authentication in Azure App Service
Authentication & OAuth
- Microsoft Entra ID Access Tokens
- OAuth 2.0 On-Behalf-Of Flow
- RFC 9728 – OAuth 2.0 Protected Resource Metadata
- RFC 8414 – OAuth 2.0 Authorization Server Metadata