Remote MCP server Entra login

30 Oct 2026

Building a remote MCP server with Microsoft Entra sign-in

We put GraphPaaS inside Claude: a remote MCP server that answers questions about Microsoft 365 tenants from the data our background sync already collected. The tools were the easy part. The sign-in took longer, and the bug that held it up came down to one line of issuer validation. These are the notes, in the order the numbers turned up.

0
real tokens accepted before the fix
2
ways in, one Entra app
12
tools today, 3 at launch
60
tool calls a minute per caller

Two ways in, one app registration

The server is FastMCP mounted inside our FastAPI app, over streamable HTTP. Nothing in it talks to Microsoft Graph: every read tool calls the same guard functions as the REST routes behind the dashboard. So the MCP can't open a door the REST API keeps shut.

Authentication takes two routes. An API key as a Bearer token, for Claude Code and any client that can send a header, scoped to one organisation and to whichever tenants the key was limited to. And Microsoft sign-in over OAuth, for claude.ai, which can't send a custom header. Sign-in runs on the same Entra app registration that signs people into the dashboard. We exposed one delegated scope on it, read-only by name, and set the app to issue v2.0 access tokens (requestedAccessTokenVersion = 2 in the manifest). Same app means the same consent and, more importantly, the same user identifier.

0: the issuer check that rejected everyone

The first end-to-end test failed with every real account we had. The OAuth provider was configured with the organizations authority, the standard choice for an app that takes work accounts from any tenant, and it derived the issuer to validate from that authority, the literal word organizations included. But a v2.0 token never carries that word. For multi-tenant apps, Microsoft's token validation guidance spells it out: the metadata's issuer is a template, https://login.microsoftonline.com/{tenantid}/v2.0, and the token's iss carries the customer's own tenant GUID. An exact string match can't pass.

ONE TOKEN FROM A CUSTOMER TENANT, FIVE CHECKS Access token, v2.0 what Entra actually sends iss …/{tenant GUID}/v2.0 tid {tenant GUID} aud our client id scp the read scope oid the person Signature against Microsoft's published signing keys pass iss == "…/organizations/v2.0" derived from the authority; no token ever matches fail, always switched off aud == our app a token minted for another API is refused pass scp has the read scope the one delegated permission we expose pass oid → a GraphPaaS user re-read on every call; no user = 403, reads nothing the gate WHAT MICROSOFT DOCUMENTS FOR MULTI-TENANT APPS iss == https://login.microsoftonline.com/{tid}/v2.0 substitute the token's own tid into the template, check tid is a GUID, then match exactly

Our fix was the line in the hook: we cleared the provider's issuer, the same override its B2C path already makes. Be clear about what that leaves. The signature is still checked against Microsoft's keys, the audience still pins the token to our app, the scope must be there, and none of that is the real gate. The gate is the next section. If you build this yourself, do the template check Microsoft documents instead of switching it off: substitute the token's tid, confirm it's a GUID, match exactly. It's a few lines, and it turns "signed by Microsoft for our app" into "signed by Microsoft, for our app, by the tenant it says it came from".

2 identities, 1 key: the oid

An MCP server that trusts "any valid Entra token for our app" lets every account in every tenant in. So a token only proves who is calling. What they may read comes from our own records. The server takes the token's oid, which Microsoft describes as the same for a user across applications, and looks up the GraphPaaS user with that object id. The dashboard's sign-in stores the same value, so Claude reads exactly what that person sees in the browser: same organisation, same tenant scope. A Microsoft account with no GraphPaaS user gets a 403 and an instruction to sign in to the dashboard first.

Microsoft's claims validation guidance warns about the shortcut: checking only the tenant and the presence of an oid can authorise every service principal in that tenant as well as its users. Matching the oid against a person somebody invited closes that. The lookup runs on every tool call, so removing someone from a team cuts them off at their next question, not at token expiry.

Where the OAuth state lives

claude.ai doesn't know our client id. It registers itself through dynamic client registration, which is why the connector form leaves Client ID and Secret empty. The MCP server answers that registration itself. Entra only ever sees our one app. In between, the OAuth proxy holds registered clients and each user's upstream Microsoft tokens: access, refresh and id tokens, which carry a name, a UPN and an IP.

The default store is a local file, and that can't work here: a file is lost on every deploy, which signs everyone out, and it isn't shared between web workers, so a login finished on one worker is unknown to the next. The state now lives in Redis behind a Fernet encryption wrapper keyed from the app secret. When a member is removed or an account is closed, their stored tokens are found by oid and deleted with everything else.

The rest of the threat model, in five lines

  • Read-only, except one tool. sync_domain is the dashboard's refresh button: tenant admins only, one domain at a time, a 15-minute cooldown, and API keys are refused. It queues another read from Microsoft and writes nothing there.
  • A model in a loop is capped. 60 calls a minute per caller, then a 429 the model can read.
  • Every call is audited without its arguments. Who, which tool, which domain and tenant. Not the filters, which can hold a name or a UPN.
  • No guessing tenants. The server is stateless and returns JSON, so there's no channel to ask the user a question mid-call. With several tenants and none named, the call is refused with a message that Claude turns into the question.
  • The URL is the root. The server is mounted under a path in the API, but the public hostname serves it at /. The form's suggested /mcp gets a 404, so our connector page says so in bold.
The pattern
Treat the token as identity and nothing more. Validate it the way the platform documents, then send every authorisation decision through the same code the rest of the product uses. That decides whether the MCP is a second front door or a second copy of the first one. What that looks like from the other side is in GraphPaaS in Claude, and the MSP view in all tenants in one question.
Sources
GraphPaaS

Sign in with the Microsoft account you already use, and Claude reads exactly what your dashboard shows.

Add the connector →

Newsletter

Get the next article by email

Practical M365 & Azure cost pieces like this one. No spam, unsubscribe anytime.