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.
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.
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_domainis 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/mcpgets a 404, so our connector page says so in bold.
- Access tokens in the Microsoft identity platform — the templated issuer for multi-tenant apps, the tid substitution, requestedAccessTokenVersion.
- Secure applications and APIs by validating claims — audience first; tenant plus oid alone also authorises service principals.
- Access token claims reference — oid is immutable and identical across applications for the same user.
- Configure an application to expose a web API — adding a delegated scope and how it arrives in the scp claim.
Sign in with the Microsoft account you already use, and Claude reads exactly what your dashboard shows.
Add the connector →