Skip to content
damionas
No. 28DevOpsSep 2, 20258 min read

Securing an Internal MCP Server Behind Entra ID With Per-Tool OAuth Scopes

The day after we shipped our MCP server to a wider engineering audience, somebody used it to query cost data for a subscription they shouldn't have been able to see. Not maliciously, they had MCP wired up before their RBAC was.

The day after we shipped our MCP server to a wider engineering audience, somebody used it to query cost data for a subscription they shouldn't have been able to see. Not maliciously, they had MCP wired up before their RBAC was. The data was returned because the MCP server was running under its own identity and didn't care who was calling.

We rebuilt the auth story. Every MCP request now carries the caller's Entra ID token, every tool checks a specific scope, and the underlying Azure call is made on the caller's behalf via OBO. This is the design and the four traps.

What changed

Before: client → MCP server (system-assigned identity) → Azure. The server's identity had Cost Management Reader at subscription scope. Anyone who could reach the server got that level.

After: client (with user token) → MCP server (validates scope, exchanges for downstream token) → Azure (with user's token). The server has no Azure RBAC of its own beyond the OBO exchange. Each user's view is exactly their RBAC, nothing more.

The Entra ID app registration

resource app 'Microsoft.Graph/applications@v1.0' = {
  uniqueName: 'mcp-cost-server'
  displayName: 'MCP Cost Server'
  identifierUris: ['api://mcp-cost-server']
  api: {
    requestedAccessTokenVersion: 2
    oauth2PermissionScopes: [
      {
        id: guid('cost.read')
        adminConsentDisplayName: 'Read cost data'
        adminConsentDescription: 'Allow the app to read cost data on the user\'s behalf'
        userConsentDisplayName: 'Read your cost data'
        type: 'User'
        value: 'cost.read'
        isEnabled: true
      }
      {
        id: guid('cost.export')
        adminConsentDisplayName: 'Export cost data'
        adminConsentDescription: 'Allow the app to export cost reports on the user\'s behalf'
        userConsentDisplayName: 'Export cost data'
        type: 'Admin'
        value: 'cost.export'
        isEnabled: true
      }
    ]
  }
}

Two scopes, not one. cost.read is a normal user-consent scope. cost.export requires admin consent because exports leave Azure and contain identifiers. Cheap to set up, and it forces a real consent prompt the first time someone hits the export tool.

Validating the incoming token

import { jwtVerify, createRemoteJWKSet } from "jose";

const jwks = createRemoteJWKSet(
  new URL(`https://login.microsoftonline.com/${TENANT_ID}/discovery/v2.0/keys`)
);

async function validateBearer(authz: string | undefined): Promise<TokenClaims> {
  if (!authz?.startsWith("Bearer ")) {
    throw new HttpError(401, "missing_bearer");
  }
  const { payload } = await jwtVerify(authz.slice(7), jwks, {
    issuer: `https://login.microsoftonline.com/${TENANT_ID}/v2.0`,
    audience: `api://mcp-cost-server`,
  });
  return payload as TokenClaims;
}

Three checks pinned: signature (via JWKS), issuer, audience. Skipping any one of these is the bug that turns a "secure" MCP server into a public one.

Per-tool scope enforcement

Where most teams trip up: the OAuth scope is on the client→server token, not the underlying Azure call. The MCP server has to look at claims.scp on its own request before deciding whether to do anything:

function requireScope(claims: TokenClaims, required: string) {
  const scopes = (claims.scp ?? "").split(" ");
  if (!scopes.includes(required)) {
    throw new HttpError(403, `missing_scope:${required}`);
  }
}

server.setRequestHandler(CallToolRequestSchema, async (req, ctx) => {
  const claims = ctx.user as TokenClaims;
  if (req.params.name === "cost_by_service") requireScope(claims, "cost.read");
  if (req.params.name === "cost_export")     requireScope(claims, "cost.export");
  // …
});

The MCP SDK doesn't ship a request-context with claims out of the box; you wrap the SSE transport's connect to attach the validated claims onto a context object the handlers read. About 30 lines of glue.

On-Behalf-Of for the downstream Azure call

import {
  ConfidentialClientApplication,
  OnBehalfOfRequest,
} from "@azure/msal-node";

const msal = new ConfidentialClientApplication({
  auth: {
    clientId: APP_ID,
    authority: `https://login.microsoftonline.com/${TENANT_ID}`,
    clientCertificate: { thumbprint: CERT_TP, privateKey: PRIVATE_KEY },
  },
});

async function exchangeForArm(userToken: string) {
  const req: OnBehalfOfRequest = {
    oboAssertion: userToken,
    scopes: ["https://management.azure.com/user_impersonation"],
  };
  const result = await msal.acquireTokenOnBehalfOf(req);
  return result!.accessToken;
}

The downstream Azure call now uses the caller's token. Cost Management returns their data, exactly the subscriptions they have RBAC on, no more.

No client secret. A certificate, mounted from Key Vault as a CSI-driven volume on the Container App. Secrets in environment variables for production OBO is the easiest way to find your app on a credential-leak dashboard.

What broke first

The OBO token cache wasn't keyed on the user. First version cached one ARM token per server instance. Second user got the first user's token, hit Cost Management, got a 403 against subscriptions they actually had access to (audience mismatch in the response). Fix: cache key is sha256(userOid + scope), TTL 30 minutes.

Conditional Access broke OBO with no obvious error. Some tenants have CA policies that block OBO entirely, or require MFA on the downstream resource. The acquireTokenOnBehalfOf call returns an interaction_required error which most code treats as a 500. The right response is a structured 403 with claims echoed back to the client so it can re-auth with the right CA challenge.

Admin consent for cost.export blocked first-time use. Until an Entra admin clicked Approve on the consent page, every user who tried the export tool saw a generic 403. The fix is a one-line check on first-run of the server that hits the Graph API and warns if admin consent is missing for any declared scope. Failing loud at startup beats failing at the user.

The audience claim disagreed with the API URL. Set api://mcp-cost-server on the app, but my client requested https://mcp-cost-server.example.com/.default. Token validated against the wrong audience and was rejected. Pin the audience explicitly on both sides, the URL is not the audience.

What I'd do differently

Use named role assignments, not scopes, for anything that maps cleanly to an Entra app role. App roles ride in the roles claim on the token and don't need explicit consent, they're admin-assigned. Scopes are right when the user is delegating their own permission; roles are right when admin is granting bulk access. Most internal MCP tools are the second.

I would NOT skip the user-token validation just because traffic is internal. "Internal-only" is a network claim, not an auth claim. The day someone exposes the server temporarily for debugging is the day a missing audience check matters.

MCPEntra IDOAuthOBOSecurity

Conversation

Reactions & comments

Liked this? Tap a reaction. Want to push back, share a war story, or ask a follow-up? Drop a comment below — replies are threaded and markdown works.

Loading conversation…

More from DevOps

See all →