Entra ID OIDC¶
Deploying the Zscaler MCP Server with Microsoft Entra ID as the OIDC identity provider. This is the deployment pattern for Microsoft-shop customers who want enterprise SSO for the MCP client (Claude Desktop, Cursor, etc.) without rolling Auth0 or Okta.
In oidc mode the server is an OAuth 2.0 protected resource (RFC 9728). It issues no tokens and runs no login flow: it publishes one metadata document naming Entra ID as the authorization server, and verifies the tokens clients bring. The MCP client performs the OAuth flow directly against Entra ID.
Note
The server holds no credential. Verifying a signature requires Entra ID’s public keys, not a secret of ours, so there is no client secret to create, store, or rotate.
Why a dedicated guide¶
Entra ID diverges from Auth0 / Okta in three ways, and each one produces a different confusing failure if you follow a generic OIDC example:
The audience is the client ID, not a separate API identifier. Configuring an Auth0-style audience causes immediate 401s.
Entra ID issues v1 access tokens by default, whose
issclaim does not match the issuer published in its own v2.0 discovery document. Every token is rejected until you opt into v2.The resource identifier must contain a path. Entra ID matches the OAuth
resourceparameter against a registered Application ID URI and refuses to register any URI ending in a slash — which is exactly what a client sends when the resource is a bare origin.
Points 2 and 3 are absolute: no client-side configuration works around either. This guide gives you the exact values that do work.
Prerequisites¶
Microsoft Entra ID tenant (any Entra ID Free, P1, or P2 tier)
Permission to register an application in Entra ID, and to grant admin consent
A running MCP server reachable at an HTTPS URL (Cloud Run, Container Apps, ECS, your own ingress, etc.)
Throughout this guide, replace:
<TENANT_ID>— your Entra Directory (tenant) ID<CLIENT_ID>— the app registration’s Application (client) IDhttps://your-mcp-server.example.com/mcp— your server’s full MCP endpoint URL, including the path
Step 1 — Register an Entra ID application¶
The application represents the MCP client, which is the OAuth client in this design. In the Azure portal:
Entra ID → App registrations → New registration
Name:
Zscaler MCP ServerSupported account types: usually “Accounts in this organizational directory only (Single tenant)”
Redirect URI: pick Mobile and desktop applications (a public client) with the URI
http://localhost:3334/oauth/callbackRegister whatever URI your client actually uses. For
mcp-remotethat means pinning the port: despite what its README says it has no fixed default, and unless you pass one it derives the port from a hash of the server URL (3335 + hash % 45816), so it will never choose 3334. Pass3334as the first argument after the server URL — in any other position it is parsed as a port, silently yieldsNaN, and the derived port is used instead with no warning. Entra ID comparesredirect_uribyte-for-byte, so a derived port fails every login.
Click Register.
On the application overview page, note the Application (client) ID and Directory (tenant) ID — you’ll need both.
Important
The Directory (tenant) ID is not your Subscription ID. They are both GUIDs on adjacent portal blades and are easy to confuse. A subscription ID in the discovery URL fails at server startup with AADSTS90002: Tenant '<guid>' not found. Confirm with az account show --query tenantId -o tsv.
Important
The redirect URI belongs to the client, not to the server, so it is a localhost address and stays the same no matter where the server is deployed. Earlier revisions of this guide used <server>/auth/callback, which was correct only while the server itself was the OAuth client.
Step 2 — Allow public client flows¶
The MCP client is a public client using the authorization-code flow with PKCE — it holds no secret.
Authentication → confirm the
http://localhost:3334/oauth/callbackredirect URI is listed under Mobile and desktop applicationsUnder Advanced settings, set Allow public client flows to Yes
Save
Step 3 — Expose the server as an API¶
This step is mandatory and is the one most often missed. Entra ID will only mint an access token whose audience is your application if the client requests a scope belonging to your application. That requires an Application ID URI and at least one delegated scope.
Entra ID → App registrations → your app → Expose an API
Application ID URI → Add → replace the suggested
api://<CLIENT_ID>with your server’s full MCP endpoint URL:https://your-mcp-server.example.com/mcp
Add a scope:
Scope name:
mcp.accessWho can consent: Admins and users
Admin consent display name:
Access the Zscaler MCP serverAdmin consent description:
Allows the signed-in user to call the Zscaler MCP server.State: Enabled
The scope’s fully-qualified name is the App ID URI and the scope name joined by a slash — that concatenation is the value the client must request:
https://your-mcp-server.example.com/mcp/mcp.access
Warning
The Application ID URI must include the path and must not end in a slash. Both halves of that sentence are load-bearing.
The client sends the resource identifier from the server’s metadata as the OAuth resource parameter, serialized through the WHATWG URL parser. That parser normalizes a bare origin by appending a slash, so https://host becomes https://host/. Entra ID then compares resource byte-for-byte against your registered Application ID URIs and rejects the request with AADSTS9010010 — while simultaneously refusing to let you register the slashed form at all (IdentifierUrisEndsWithSlash: ValueCannotEndWithSlash).
A URI that already has a path is left alone by the parser: https://host/mcp stays https://host/mcp. That is why the resource identifier must be the full MCP endpoint URL rather than the server’s origin.
Equivalent CLI, if you prefer not to click through the portal:
az rest --method PATCH \
--uri "https://graph.microsoft.com/v1.0/applications(appId='<CLIENT_ID>')" \
--headers 'Content-Type=application/json' \
--body '{"identifierUris":["https://your-mcp-server.example.com/mcp"]}'
az rest --method PATCH \
--uri "https://graph.microsoft.com/v1.0/applications(appId='<CLIENT_ID>')" \
--headers 'Content-Type=application/json' \
--body '{"api":{"oauth2PermissionScopes":[{
"id":"'"$(uuidgen | tr 'A-Z' 'a-z')"'",
"value":"mcp.access","type":"User","isEnabled":true,
"adminConsentDisplayName":"Access the Zscaler MCP server",
"adminConsentDescription":"Allows the signed-in user to call the Zscaler MCP server.",
"userConsentDisplayName":"Access the Zscaler MCP server",
"userConsentDescription":"Allows you to call the Zscaler MCP server."}]}}'
Step 4 — Request v2 access tokens¶
Entra ID’s requestedAccessTokenVersion defaults to null, which means v1. A v1 access token carries iss: https://sts.windows.net/<TENANT_ID>/, but the server reads its expected issuer from the v2.0 discovery document, which publishes https://login.microsoftonline.com/<TENANT_ID>/v2.0. The mismatch rejects every token with 401 invalid_token even though sign-in succeeded.
Entra ID → App registrations → your app → Manifest, then set:
{ "api": { "requestedAccessTokenVersion": 2 } }
In the older AAD manifest view the same setting appears as a top-level "accessTokenAcceptedVersion": 2.
Or via CLI:
az rest --method PATCH \
--uri "https://graph.microsoft.com/v1.0/applications(appId='<CLIENT_ID>')" \
--headers 'Content-Type=application/json' \
--body '{"api":{"requestedAccessTokenVersion":2}}'
Note
Entra ID refuses this change while the Application ID URI uses the legacy https://<tenant>/<app> form. Set the App ID URI as described in Step 3 first, then apply the token version.
Important
With requestedAccessTokenVersion: 2, the aud claim is the client ID GUID — not the Application ID URI, even though the client requested a scope named after that URI. This is the Entra-specific behaviour called out at the top of this guide, and it is why the expected audience stays the client ID.
Step 5 — Configure API permissions¶
The default Microsoft Graph → User.Read delegated permission added at registration is sufficient; openid and profile are always available and need no grant. Add nothing else — the scope that matters is your own from Step 3, not a Graph permission.
If your tenant requires admin consent for user-consentable scopes, grant it now: API permissions → Grant admin consent for [your organization]. Otherwise each user consents once at first sign-in.
Step 6 — Deploy the MCP server¶
Configuration is environment variables only — no wrapper script and no extra packages:
export ZSCALER_MCP_AUTH_ENABLED=true
export ZSCALER_MCP_AUTH_MODE=oidc
# The IdP's discovery document. Issuer and JWKS URI are read from it at startup.
export OIDCPROXY_CONFIG_URL="https://login.microsoftonline.com/<TENANT_ID>/v2.0/.well-known/openid-configuration"
# The app registration's client ID — also supplies the default audience.
export OIDCPROXY_CLIENT_ID="<CLIENT_ID>"
# THIS server's OAuth resource identifier. For Entra ID it MUST include the
# MCP path and MUST match the Application ID URI from Step 3 exactly.
export OIDCPROXY_BASE_URL="https://your-mcp-server.example.com/mcp"
export ZSCALER_MCP_ALLOW_HTTP=true # only if TLS is terminated upstream (Cloud Run, etc.)
The variables keep their historical OIDCPROXY_ prefix so existing deployments keep working. OIDCPROXY_CLIENT_SECRET is ignored if it is still set, and can be deleted.
Warning
OIDCPROXY_BASE_URL must be the full MCP endpoint URL including the path, and must be byte-identical to the Application ID URI registered in Step 3. On other IdPs the server’s origin is a fine resource identifier; on Entra ID it cannot work, for the trailing-slash reason explained in Step 3.
Setting the path also moves the metadata document, by design: it is published at /.well-known/oauth-protected-resource/mcp rather than /.well-known/oauth-protected-resource. Clients find it either way — they probe the path-suffixed form first, and the 401 challenge names the exact URL in its resource_metadata parameter.
Warning
Do not set OIDCPROXY_REQUIRED_SCOPES on Entra ID. That variable does double duty: it is published as scopes_supported in the metadata document and enforced against the token by exact string match. Entra ID advertises the fully-qualified https://your-mcp-server.example.com/mcp/mcp.access but issues scp: "mcp.access" — the short name only. Setting it therefore gets a user past sign-in and then fails the call with 403 insufficient_scope. Supply the scope from the client instead, as in Step 7.
Step 7 — Configure the MCP client¶
Entra ID does not support Dynamic Client Registration, so the client needs the client ID from Step 1. It also needs to be told which scope to request, because the server does not advertise one (see the second warning in Step 6). Claude Desktop (~/Library/Application Support/Claude/claude_desktop_config.json):
{
"mcpServers": {
"zscaler-mcp-server": {
"command": "npx",
"args": [
"-y", "mcp-remote",
"https://your-mcp-server.example.com/mcp", "3334",
"--static-oauth-client-info", "{\"client_id\":\"<CLIENT_ID>\"}",
"--static-oauth-client-metadata", "{\"scope\":\"https://your-mcp-server.example.com/mcp/mcp.access\"}"
]
}
}
}
Three details, each of which fails the login on its own if wrong:
"3334"pins the OAuth callback port and has to stay immediately after the URL — that is the only positionmcp-remotereads it from, and elsewhere it is ignored silently, leaving a derived port that Entra ID will reject as an unregisteredredirect_uri.--static-oauth-client-infosupplies the client ID that DCR would otherwise have obtained.--static-oauth-client-metadatasupplies the scope.mcp-remoteresolves the scope in the order static client metadata → theWWW-Authenticateheader →scopes_supportedfrom the metadata document, so this is the highest-priority source and the only one available here. Without it the client falls back to generic scopes such asopenid profile offline_access, which do not name your resource, and Entra ID rejects the request withAADSTS9010010.
Cursor (~/.cursor/mcp.json): same approach — Cursor and Claude Desktop both use the mcp-remote flow.
Note there is no Authorization header. Restart the MCP client; on the first MCP call mcp-remote opens your browser to the Entra ID sign-in page, and after consent it caches the refresh token and won’t prompt again until it expires.
Tip
mcp-remote caches OAuth state per server URL under ~/.mcp-auth. After changing any value in this guide, delete that directory and kill stray processes (rm -rf ~/.mcp-auth && pkill -f mcp-remote) before retrying, or the client will replay the old configuration.
Verification¶
The startup log states exactly what the server will accept:
OIDC auth configured as a protected resource (issuer=https://login.microsoftonline.com/<TENANT_ID>/v2.0, resource=https://your-mcp-server.example.com/mcp, audience=<CLIENT_ID>)
The metadata document should name your tenant as the authorization server. Note the path suffix:
curl -s https://your-mcp-server.example.com/.well-known/oauth-protected-resource/mcp
{
"resource": "https://your-mcp-server.example.com/mcp",
"authorization_servers": ["https://login.microsoftonline.com/<TENANT_ID>/v2.0"],
"bearer_methods_supported": ["header"]
}
An unauthenticated call must return 401 and point at that document:
curl -si -X POST https://your-mcp-server.example.com/mcp \
-H 'Content-Type: application/json' \
-H 'Accept: application/json, text/event-stream' \
-d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"c","version":"1"}}}' \
| grep -i 'www-authenticate'
www-authenticate: Bearer error="invalid_token", error_description="Authentication required",
resource_metadata="https://your-mcp-server.example.com/.well-known/oauth-protected-resource/mcp"
/.well-known/oauth-authorization-server, /register, /authorize and /token are supposed to return 404 — this server is a protected resource, not an authorization server.
To confirm the Entra side without launching a browser, check the app registration directly. All four values below must be as shown:
az rest --method GET \
--uri "https://graph.microsoft.com/v1.0/applications(appId='<CLIENT_ID>')?\$select=identifierUris,api,publicClient,isFallbackPublicClient"
identifierUris ["https://your-mcp-server.example.com/mcp"] # path, no trailing slash
api.requestedAccessTokenVersion 2 # v2 issuer
api.oauth2PermissionScopes [{ "value": "mcp.access", … }] # the scope clients request
publicClient.redirectUris ["http://localhost:3334/oauth/callback"]
isFallbackPublicClient true # public client flows allowed
A successful sign-in looks like this in the server log — a 401 challenge, metadata discovery, then authenticated traffic:
"GET /.well-known/oauth-protected-resource/mcp HTTP/1.1" 200 OK
"POST /mcp HTTP/1.1" 401 Unauthorized
"GET /.well-known/oauth-protected-resource/mcp HTTP/1.1" 200 OK
"POST /mcp HTTP/1.1" 200 OK
Common errors¶
Symptom |
Likely cause |
|---|---|
|
The |
401 |
The app is still issuing v1 tokens. Set |
401 |
The client requested a generic scope, so Entra ID issued a Microsoft Graph token. Supply your own scope via |
403 |
|
|
A Subscription ID was used where the Directory (tenant) ID belongs. Get the right one with |
|
Entra ID never accepts an Application ID URI ending in |
Server exits with |
Required configuration is missing. |
|
Wrong tenant ID in the discovery URL, or no egress from the server’s host to |
|
The client’s callback isn’t registered. The error names the |
|
Wrong client ID or tenant ID. |
|
The signing-in user is in a different tenant than the app registration. Check the supported account types in Step 1. |
|
The client sent a |
|
The server isn’t running in |
Login succeeds but the client keeps re-prompting or replays old settings |
Stale |
See also¶
MCP Client Authentication — the full set of authentication modes, including JWT and api-key alternatives.
Azure Deployment — deploying the server to Azure Container Apps / VM / AKS.
Process Lifecycle Management — credential rotation without container restart.