Overview
The Model Context Protocol (MCP) lets Claude.ai talk to external tools and data sources over HTTP. When those servers live inside a corporate private network, you need to layer in proper authentication. Our organisation uses Microsoft Entra ID (formerly Azure AD) as the identity provider, and we wanted Claude's connector to authenticate through it — without exposing credentials.
This post covers two distinct scenarios we went through:
OAuth 2.0 — A Simple Overview
Before diving into the proxy mechanics, it's worth grounding ourselves in how OAuth 2.0 Authorization Code Flow works — because every design decision in this project traces back to a specific part of this protocol.
OAuth 2.0 (RFC 6749) is an authorization framework — it lets a third-party application (Claude.ai) obtain limited access to a service (your MCP server) on behalf of a user, without the user handing over their password. It defines four roles:
| Role | In our setup | What it does |
|---|---|---|
| Resource Owner | The employee / user | Grants permission to access their data |
| Client | Claude.ai | Requests access on behalf of the user |
| Authorization Server | Microsoft Entra ID | Issues tokens after authenticating the user |
| Resource Server | Your MCP Server | Validates tokens and serves protected data |
The Authorization Code Flow — step by step
The optional resource parameter (RFC 8707)
RFC 8707 — Resource Indicators for OAuth 2.0
adds an optional resource parameter to /authorize and /token.
It tells the authorization server which API the token is intended for, so the server can
embed the correct audience (aud) claim in the JWT.
Claude sends this parameter automatically, using the MCP server URL as the resource value. Microsoft Entra validates the resource against the App ID URI registered in the app manifest — and that's exactly where the mismatch that drives all of our proxy logic originates.
OAuth 2.0 Discovery — how clients find the auth server
Rather than hard-coding endpoint URLs, OAuth 2.0 clients use Authorization Server Metadata (RFC 8414) and Protected Resource Metadata (RFC 9728) — both served as JSON documents at well-known URLs. Claude.ai follows this discovery chain before initiating any auth flow, which is why getting those discovery endpoints right is the foundation of everything else in this guide.
Problem A — The Discovery Path Mismatch
When Claude.ai connects to a remote MCP server, it follows the OAuth 2.0 Protected Resource Metadata discovery sequence defined in RFC 9728. Here is the exact sequence of GET calls Claude makes:
Step 1 — Resource-specific protected resource metadata
Claude calls: GET https://example.com/.well-known/protected-resource/FirstMCPServer
This is the resource-scoped discovery endpoint. It should return a JSON document describing the auth server for FirstMCPServer specifically.
Step 2 — Global protected resource metadata (fallback #1)
If Step 1 returns 404, Claude falls back to: GET https://example.com/.well-known/protected-resource
This is the global catch-all. If it returns metadata, it applies to all resources under example.com.
Step 3 — Authorization server discovery (fallback #2)
Still nothing? Claude tries: GET https://example.com/.well-known/oauth-authorization-server
This is the classic OAuth 2.0 Authorization Server Metadata endpoint per RFC 8414.
Step 4 — Legacy authorize endpoint (final fallback)
As the absolute last resort, Claude hits: GET https://example.com/authorize
At this point any fine-grained per-resource control is lost — it's purely global.
🔴 The Core Problem
Our server served the protected-resource document at
/.well-known/protected-resource (no path suffix), but Claude was looking for
/.well-known/protected-resource/FirstMCPServer.
Since the resource name in the document was also api://<Application-Id> rather than
https://example.com/FirstMCPServer, Claude's resource matching failed
and it fell all the way through to the global fallback.
Global Auth — Single Authorization for All Servers
If all your MCP servers share one Microsoft Entra application and you don't need per-server isolation, the simplest fix is to expose the standard OAuth Authorization Server Metadata (RFC 8414) at the well-known URL and let Claude's fallback mechanism find it.
What to serve at /.well-known/oauth-authorization-server
{
"issuer": "https://example.com",
"authorization_endpoint": "https://login.microsoftonline.com/<tenant-id>/oauth2/v2.0/authorize",
"token_endpoint": "https://login.microsoftonline.com/<tenant-id>/oauth2/v2.0/token",
"scopes_supported": ["api://<app-id>/<your-scope>"],
"jwks_uri": "https://login.microsoftonline.com/<tenant-id>/discovery/v2.0/keys",
"response_types_supported": ["code"],
"grant_types_supported": ["authorization_code"],
"code_challenge_methods_supported": ["S256"]
}
Your MCP endpoint should return HTTP 401 WWW-Authenticate: Bearer for unauthenticated requests and
200 with the MCP response once the token is validated.
| Endpoint | Responsibility | Returns |
|---|---|---|
| /.well-known/oauth-authorization-server | Auth server discovery | JSON metadata |
| /<MCPServer> | MCP tool calls | 401 (unauth) / 200 (auth) |
Problem B — Multiple MCP Servers, Different Auth
Our real scenario: two distinct MCP servers serving different departments, each needing its own Entra application (different scopes, different app registrations).
https://example.com/FirstMCPServerAuth:
FirstAuth Entra AppScope:
api://hr-app/read
https://example.com/SecondMCPServerAuth:
SecondAuth Entra AppScope:
api://compliance-app/read
OAuth 2.0 supports the optional resource parameter (RFC 8707) to scope a token to a specific protected resource.
The problem: Claude sends the resource parameter along to Microsoft's /token and
/authorize endpoints, but Entra validates that the resource matches the registered application URI.
When the mismatch occurs, the whole auth flow fails.
🔴 Root Causes
1. Resource mismatch at /authorize — Claude sends resource=https://example.com/FirstMCPServer but the Entra app registration URI is api://<app-id>.
2. Callback mismatch — The redirect URI Claude uses must be registered exactly in the Entra app. Our proxy sits in between and must rewrite callbacks.
3. Scope validation at /token — Entra cross-validates scope against resource; sending both can cause failures.
The Full Solution Flow
✅ Key Insight
We introduce a lightweight Auth Proxy per authorization server. The proxy's job: intercept Claude's OAuth calls, strip/rewrite parameters that Entra rejects, inject the correct callback URI, and relay the result back to Claude. The MCP servers stay untouched — they just validate JWTs.
Step 1 — Resource-specific protected-resource metadata
We now correctly serve the resource-scoped endpoint. Claude hits:
GET https://example.com/.well-known/protected-resource/FirstMCPServer
and receives:
{
"resource": "https://example.com/FirstMCPServer",
"authorization_servers": ["https://example.com/FirstAuth"],
"scopes_supported": ["api://hr-app/read"],
"bearer_methods_supported": ["header"]
}
Now Claude knows exactly which authorization server to talk to for this resource.
Step 2 — Authorization server metadata
Claude follows up with:
GET https://example.com/.well-known/oauth-authorization-server/FirstAuth
We return our proxy's endpoints — not Microsoft's directly:
{
"issuer": "https://example.com/FirstAuth",
"authorization_endpoint": "https://example.com/FirstAuth/authorize",
"token_endpoint": "https://example.com/FirstAuth/token",
"scopes_supported": ["api://hr-app/read"],
"jwks_uri": "https://login.microsoftonline.com/<tenant-id>/discovery/v2.0/keys",
"response_types_supported": ["code"],
"code_challenge_methods_supported": ["S256"]
}
Step 3 — /authorize proxy
Claude redirects the user's browser to our /FirstAuth/authorize. Our proxy:
Remove resource parameter
Entra validates resource against the app registration. We strip it to avoid mismatch errors.
Rewrite redirect_uri
Replace Claude's callback with our own: https://example.com/FirstAuth/oauth/callback (must be registered in Entra app).
Stash original Claude callback in session / state
We encode Claude's original redirect_uri + state in our own state parameter so we can replay it later.
Forward to Microsoft
Redirect to https://login.microsoftonline.com/<tenant-id>/oauth2/v2.0/authorize.
Step 4 — /oauth/callback
Microsoft redirects back to our proxy callback with ?code=…&state=….
Decode original Claude callback from state
We extract Claude's real redirect_uri and original state.
Re-add resource parameter
Claude's callback expects the resource parameter to be present in the code exchange.
Forward code to Claude
Redirect to https://claude.ai/api/mcp/auth/callback?code=…&state=…&resource=…
Step 5 — /token proxy
Claude does a POST to exchange the code. Our proxy:
Strip scope parameter
When Entra sees both resource and scope in a token request it can reject the call. We remove scope and let Entra apply defaults from the app manifest.
Rewrite redirect_uri
Must match exactly what was used in /authorize — our proxy callback, not Claude's.
Call Microsoft /token
POST to https://login.microsoftonline.com/<tenant-id>/oauth2/v2.0/token
Return access token to Claude
Relay the full token response JSON verbatim. Claude will attach it as Authorization: Bearer … on subsequent MCP calls.
Complete Python Implementation
The following is a production-ready FastAPI application that implements the Auth Proxy for one MCP server. To add a second server (e.g., Compliance), duplicate the router with a different prefix and Entra app credentials. Run with Uvicorn.
fastapi==0.110.0 uvicorn[standard]==0.29.0 httpx==0.27.0 python-jose[cryptography]==3.3.0 python-dotenv==1.0.1 mcp==1.3.0
# Microsoft Entra — HR App TENANT_ID=your-tenant-id HR_CLIENT_ID=hr-entra-app-client-id HR_CLIENT_SECRET=hr-entra-app-client-secret HR_SCOPE=api://hr-app-id/read # Microsoft Entra — Compliance App COMPLIANCE_CLIENT_ID=compliance-entra-app-client-id COMPLIANCE_CLIENT_SECRET=compliance-entra-app-client-secret COMPLIANCE_SCOPE=api://compliance-app-id/read # Server BASE_URL=https://example.com CLAUDE_CALLBACK_BASE=https://claude.ai/api/mcp/auth/callback
import os, json, urllib.parse, base64 from dotenv import load_dotenv import httpx from fastapi import FastAPI, Request, HTTPException from fastapi.responses import RedirectResponse, JSONResponse from jose import jwt, JWTError from jose.backends import RSAKey import uvicorn from mcp.server.fastmcp import FastMCP load_dotenv() TENANT_ID = os.environ["TENANT_ID"] BASE_URL = os.environ["BASE_URL"] # https://example.com CLAUDE_CALLBACK_BASE = os.environ["CLAUDE_CALLBACK_BASE"] ENTRA_AUTH = f"https://login.microsoftonline.com/{TENANT_ID}/oauth2/v2.0/authorize" ENTRA_TOKEN = f"https://login.microsoftonline.com/{TENANT_ID}/oauth2/v2.0/token" # Microsoft publishes signing keys at a well-known, stable URL per tenant. # Map directly — no need for a dynamic JWKS URI lookup. ENTRA_JWKS_URL = f"https://login.microsoftonline.com/{TENANT_ID}/discovery/v2.0/keys" # Cache: loaded once at startup, refreshed only on key-not-found (kid mismatch) _jwks_cache: dict | None = None async def get_microsoft_keys(force_refresh: bool = False) -> dict: """Return Microsoft's public signing keys, using a simple in-process cache.""" global _jwks_cache if _jwks_cache is None or force_refresh: async with httpx.AsyncClient() as c: resp = await c.get(ENTRA_JWKS_URL, timeout=5) resp.raise_for_status() _jwks_cache = resp.json() return _jwks_cache # ── App config per MCP server ──────────────────────────────────────────────── APPS = { "FirstAuth": { "client_id": os.environ["HR_CLIENT_ID"], "client_secret": os.environ["HR_CLIENT_SECRET"], "scope": os.environ["HR_SCOPE"], "resource": f"{BASE_URL}/FirstMCPServer", }, "SecondAuth": { "client_id": os.environ["COMPLIANCE_CLIENT_ID"], "client_secret": os.environ["COMPLIANCE_CLIENT_SECRET"], "scope": os.environ["COMPLIANCE_SCOPE"], "resource": f"{BASE_URL}/SecondMCPServer", }, } app = FastAPI(title="MCP Auth Proxy") # ── Helper: validate incoming Bearer token ─────────────────────────────────── async def verify_token(request: Request) -> dict: auth = request.headers.get("Authorization", "") if not auth.startswith("Bearer "): raise HTTPException( status_code=401, headers={"WWW-Authenticate": 'Bearer realm="mcp"'}, detail="Missing bearer token", ) token = auth[7:] # Extract the key ID from the token header so we can pick the right key header = jwt.get_unverified_header(token) kid = header.get("kid") # Try cached keys first; if kid is missing, force a refresh once # (Microsoft rotates keys periodically — this handles the transition) jwks = await get_microsoft_keys() known_kids = {k["kid"] for k in jwks.get("keys", [])} if kid not in known_kids: jwks = await get_microsoft_keys(force_refresh=True) try: # python-jose accepts a JWKS dict directly — it selects the key by kid claims = jwt.decode(token, jwks, algorithms=["RS256"], options={"verify_aud": False}) return claims except JWTError as e: raise HTTPException(status_code=401, detail=str(e)) # ═══════════════════════════════════════════════════════════════════════════ # DISCOVERY ENDPOINTS # ═══════════════════════════════════════════════════════════════════════════ @app.get("/.well-known/protected-resource/{server_name}") async def protected_resource_metadata(server_name: str): """Resource-specific protected resource metadata (RFC 9728).""" auth_map = { "FirstMCPServer": "FirstAuth", "SecondMCPServer": "SecondAuth", } auth_name = auth_map.get(server_name) if not auth_name: raise HTTPException(status_code=404) cfg = APPS[auth_name] return JSONResponse({ "resource": cfg["resource"], "authorization_servers": [f"{BASE_URL}/{auth_name}"], "scopes_supported": [cfg["scope"]], "bearer_methods_supported": ["header"], }) @app.get("/.well-known/oauth-authorization-server/{auth_name}") async def as_metadata(auth_name: str): """Authorization Server Metadata (RFC 8414) — points at our proxy.""" if auth_name not in APPS: raise HTTPException(status_code=404) cfg = APPS[auth_name] base = f"{BASE_URL}/{auth_name}" return JSONResponse({ "issuer": base, "authorization_endpoint": f"{base}/authorize", "token_endpoint": f"{base}/token", "scopes_supported": [cfg["scope"]], "jwks_uri": ENTRA_JWKS_URL, "response_types_supported": ["code"], "grant_types_supported": ["authorization_code"], "code_challenge_methods_supported": ["S256"], }) # ═══════════════════════════════════════════════════════════════════════════ # /authorize — strip resource, rewrite redirect, forward to Entra # ═══════════════════════════════════════════════════════════════════════════ @app.get("/{auth_name}/authorize") async def proxy_authorize(auth_name: str, request: Request): if auth_name not in APPS: raise HTTPException(status_code=404) cfg = APPS[auth_name] params = dict(request.query_params) # ① Stash Claude's original redirect_uri + state so we can replay later original_state = { "claude_redirect_uri": params.pop("redirect_uri", ""), "claude_state": params.get("state", ""), } encoded_state = base64.urlsafe_b64encode( json.dumps(original_state).encode() ).decode() # ② Strip resource — Entra validates it against app registration URI params.pop("resource", None) # ③ Inject our callback + packed state params["redirect_uri"] = f"{BASE_URL}/{auth_name}/oauth/callback" params["state"] = encoded_state params["client_id"] = cfg["client_id"] entra_url = ENTRA_AUTH + "?" + urllib.parse.urlencode(params) return RedirectResponse(entra_url, status_code=302) # ═══════════════════════════════════════════════════════════════════════════ # /oauth/callback — Microsoft redirects here; we forward to Claude # ═══════════════════════════════════════════════════════════════════════════ @app.get("/{auth_name}/oauth/callback") async def oauth_callback(auth_name: str, request: Request): if auth_name not in APPS: raise HTTPException(status_code=404) cfg = APPS[auth_name] params = dict(request.query_params) code = params.get("code") state = params.get("state", "") # Decode the stashed state try: original = json.loads(base64.urlsafe_b64decode(state + "==")) except Exception: raise HTTPException(status_code=400, detail="Invalid state") claude_redirect = original["claude_redirect_uri"] claude_state = original["claude_state"] # Forward to Claude with code + resource re-added callback_params = { "code": code, "state": claude_state, "resource": cfg["resource"], } final_url = claude_redirect + "?" + urllib.parse.urlencode(callback_params) return RedirectResponse(final_url, status_code=302) # ═══════════════════════════════════════════════════════════════════════════ # /token — strip scope, fix redirect_uri, forward to Entra # ═══════════════════════════════════════════════════════════════════════════ @app.post("/{auth_name}/token") async def proxy_token(auth_name: str, request: Request): if auth_name not in APPS: raise HTTPException(status_code=404) cfg = APPS[auth_name] body = dict(await request.form()) # ① Strip scope to avoid Entra resource/scope conflict body.pop("scope", None) # ② Must match the redirect_uri used in /authorize body["redirect_uri"] = f"{BASE_URL}/{auth_name}/oauth/callback" body["client_id"] = cfg["client_id"] body["client_secret"] = cfg["client_secret"] async with httpx.AsyncClient() as c: resp = await c.post(ENTRA_TOKEN, data=body) return JSONResponse(resp.json(), status_code=resp.status_code) # ═══════════════════════════════════════════════════════════════════════════ # MCP SERVERS — protected with JWT validation # ═══════════════════════════════════════════════════════════════════════════ # ── HR MCP Server ──────────────────────────────────────────────────────────── hr_mcp = FastMCP("HR MCP Server") @hr_mcp.tool() async def get_employee(employee_id: str) -> dict: """Return employee details from the HR system.""" # Replace with real HR data source call return {"id": employee_id, "name": "Jane Smith", "department": "Engineering"} @app.api_route("/FirstMCPServer", methods=["GET", "POST"]) async def hr_mcp_endpoint(request: Request): # Guard: 401 if no/invalid token await verify_token(request) # Delegate to FastMCP ASGI handler hr_asgi = hr_mcp.get_asgi_app() return await hr_asgi(request.scope, request.receive, request._send) # ── Compliance MCP Server ───────────────────────────────────────────────────── compliance_mcp = FastMCP("Compliance MCP Server") @compliance_mcp.tool() async def get_policy(policy_id: str) -> dict: """Return compliance policy details.""" return {"id": policy_id, "title": "Data Retention", "status": "active"} @app.api_route("/SecondMCPServer", methods=["GET", "POST"]) async def compliance_mcp_endpoint(request: Request): await verify_token(request) compliance_asgi = compliance_mcp.get_asgi_app() return await compliance_asgi(request.scope, request.receive, request._send) # ═══════════════════════════════════════════════════════════════════════════ # ENTRY POINT # ═══════════════════════════════════════════════════════════════════════════ if __name__ == "__main__": uvicorn.run( "main:app", host="0.0.0.0", port=8000, reload=False, workers=4, # In production add: ssl_keyfile + ssl_certfile )
uvicorn main:app --host 0.0.0.0 --port 443 --ssl-keyfile /etc/ssl/key.pem --ssl-certfile /etc/ssl/cert.pem --workers 4 behind an internal load balancer. Make sure your TLS certificate covers example.com and all subpaths.
Microsoft Entra App Registration Checklist
| Setting | Value |
|---|---|
| Redirect URI (HR app) | https://example.com/FirstAuth/oauth/callback |
| Redirect URI (Compliance app) | https://example.com/SecondAuth/oauth/callback |
| Supported account types | Accounts in this organisational directory only |
| API exposed — App ID URI | api://<client-id> |
| API exposed — Scope | read (or your custom scope name) |
| Token type | Access tokens (v2 endpoint) |
| PKCE | Required (S256) |
Key Takeaways
Connecting Claude.ai to private MCP servers through Microsoft Entra is absolutely doable, but requires respecting the exact discovery URL structure Claude follows. The critical points:
Match the resource name exactly
The resource field in your /.well-known/protected-resource/<name> response must equal the MCP server URL Claude is calling — not your Entra app registration URI.
Use a proxy for parameter rewriting
Strip resource at /authorize, strip scope at /token, and always rewrite redirect_uri to your registered callback.
Encode state carefully
The proxy must stash Claude's original redirect and state in the OAuth state parameter and replay them faithfully after the Entra callback.
Always return 401 first
Your MCP endpoints must send HTTP 401 WWW-Authenticate: Bearer for unauthenticated requests — this is what tells Claude to start the OAuth flow.
References & Further Reading
Every design decision in this guide is rooted in one of the following specifications or official docs. Bookmark these — they're the ground truth when something breaks.
OAuth 2.0 Core Specifications
| RFC / Spec | Title | Why it matters here |
|---|---|---|
| RFC 6749 | The OAuth 2.0 Authorization Framework | Foundation — defines roles, flows, grant types, and token types |
| RFC 6750 | Bearer Token Usage | Defines the Authorization: Bearer <token> header pattern MCP uses |
| RFC 7636 | PKCE — Proof Key for Code Exchange | Claude uses S256; Entra app must have PKCE enabled |
| RFC 8414 | OAuth 2.0 Authorization Server Metadata | The /.well-known/oauth-authorization-server discovery document Claude reads |
| RFC 8707 | Resource Indicators for OAuth 2.0 | The optional resource parameter Claude sends — root cause of the Entra mismatch |
| RFC 9728 | OAuth 2.0 Protected Resource Metadata | The /.well-known/protected-resource/<name> endpoint Claude checks first |
| RFC 9700 | Best Current Practice for OAuth 2.0 Security | Security recommendations: PKCE, state validation, exact redirect URI matching |
Microsoft Entra ID (Azure AD) Docs
| Resource | What you'll find there |
|---|---|
| Auth Code Flow (v2) | Microsoft's own walkthrough of the authorization code flow against Entra, with exact parameter names |
| OIDC Discovery (common) | Live JSON — all Entra endpoints, signing key URLs, supported scopes and claims |
| Access Token Reference | Claims structure, signing algorithm (RS256), and how to validate JWTs issued by Entra |
| App Roles & Scopes | How to define and expose API scopes (api://app-id/scope-name) in an Entra app registration |
| National Cloud Endpoints | If you're on Azure Government or China cloud, the base URLs differ from the ones used in this guide |
MCP & Claude Docs
| Resource | What you'll find there |
|---|---|
| MCP Specification | Official protocol spec — transport, message format, tool/resource/prompt primitives |
| MCP Transports | HTTP + SSE transport details — how Claude communicates with remote MCP servers |
| Claude MCP Integration | How to configure remote MCP servers in Claude.ai settings |
| MCP Python SDK | The FastMCP class and mcp.run() used in our server implementation |
<tenant-id> with your Azure AD Directory (tenant) ID found in the Entra portal.https://login.microsoftonline.com/<tenant-id>/oauth2/v2.0/authorize
https://login.microsoftonline.com/<tenant-id>/oauth2/v2.0/token
https://login.microsoftonline.com/<tenant-id>/discovery/v2.0/keys
https://login.microsoftonline.com/<tenant-id>/v2.0/.well-known/openid-configuration