February 26th, 2026
0 reactions

Building a Secure MCP Server with OAuth 2.1 and Azure AD: Lessons from the Field

Juan Burckhardt
Principal Software Engineer

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:

  1. Single hop: Requests go directly to the applicationโ€”no API gateway in the middle adding latency
  2. JWKS caching: Token validation drops to sub-millisecond times after initial key fetch
  3. Always warm: Setting minReplicas=1 eliminates cold starts (~$30-50/month)
  4. Self-contained: The container image includes everything, making it portable to any environment
  5. 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-jwt policy 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:

  1. Authorization Server: Issues tokens (Azure AD in our case)
  2. 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-resource endpoint 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 define MCP.Tools.Read for read-only access or MCP.Tools.Write for operations that modify data. In Azure AD, scope names follow the format api://{client-id}/{scope-name}. The api:// 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

  1. Cache JWKS aggressively: A 1-hour cache dramatically reduces latency while still handling key rotation.
  2. Validate token expiry before OBO: A 5-minute buffer prevents frustrating “token expired during exchange” errors.
  3. Plan for secretless deployments: Federated Identity Credentials should be the production default.
  4. Implement Protected Resource Metadata: MCP clients need /.well-known/oauth-protected-resource to discover auth requirements.
  5. Handle scope extraction carefully: Azure AD tokens may have scopes in scp (space-separated string) or roles (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_ID identifies your Azure AD directory. The CLIENT_ID identifies your specific app registration. The FEDERATED_CREDENTIAL_OID is 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:

  1. Get your Managed Identity’s Principal ID from the deployment output
  2. In Azure Portal, go to App Registrations > Your App > Certificates & secrets > Federated credentials
  3. Add a credential with:
    • Issuer: https://login.microsoftonline.com/{tenant-id}/v2.0
    • Subject Identifier: The Managed Identity’s Principal ID
    • Audience: api://AzureADTokenExchange

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

Authentication & OAuth

MCP Protocol

Author

Juan Burckhardt
Principal Software Engineer

๐Ÿ‘จโ€๐Ÿ‘งโ€๐Ÿ‘ฆ Proud Father of Two With over 15 years of experience spanning software engineering, electronics, and project management, I've had the privilege of working across diverse industries such as mining, telecommunications, government, health, and financial services. My journey has given me a strong foundation in the intersection of technology and business, with a key focus on delivering innovative solutions. ๐Ÿš€ Passionate About Technology Iโ€™m deeply interested in ...

More about author