MCP Deep Dive, Part 6: MCP Authentication With OAuth and Entra ID, Done Right
An API key is not MCP authentication. Here's how to secure MCP servers as OAuth 2.1 resource servers with Entra ID — tokens, audiences, and identity.
- Author
- Randhir Jassal
- Published
- Reading time
- 15 min read
- Views
- 16 views
The fastest way to turn a promising MCP rollout into a security incident is to "add auth later" with a static API key. An MCP server is a network endpoint that an autonomous agent will call thousands of times a day on behalf of many tenants — it needs real identity, cryptographically proven, on every single call. This part is how to do MCP authentication properly: OAuth 2.1, Entra ID, and the one question auth actually answers.
This is Part 6 of a 15-part deep dive on Model Context Protocol (MCP). Parts 1–5 built the why, the architecture, the server, the client, and the tools. Now the security trio begins. This part is authentication — who is calling. Part 7 is authorization — what they may do. Part 8 is defending against abuse. Here we establish identity on Mattrx's servers, cryptographically, so everything downstream can trust it.
TL;DR
| Aspect | API key (before) | OAuth 2.1 + Entra (after) |
|---|---|---|
| Credential | static, shared, long-lived | short-lived bearer token |
| Identity | none | verifiable (tenant, user, scopes) |
| Server role | key checker | OAuth Resource Server |
| Discovery | out-of-band, manual | Protected Resource Metadata |
| Validation | string compare | signature + issuer + audience + expiry |
| Confused deputy | vulnerable | audience-bound |
| Tenant | guessed from args | from validated token claims |
- An API key is not MCP authentication — MCP servers are OAuth 2.1 Resource Servers per the spec.
- Auth only matters across the network — stdio (local) needs none; HTTP needs OAuth.
- The server publishes Protected Resource Metadata (RFC 9728) so clients discover the auth server (Entra).
- Validate every token: signature (JWKS) + issuer + audience + expiry — fail any check → 401.
- Bind the audience to this server → block the confused-deputy / token-passthrough attack.
- Build the AiPrincipal (tenant, user, scopes) from validated claims — never from tool arguments.
- M2M (client credentials) for agent-as-itself; user-delegated (auth code + PKCE) for agent-on-behalf-of-user.
- Short-lived tokens (~60 min) replaced the pile of static, long-lived, unrotatable API keys.
- One OAuth/Entra boundary secures all three Mattrx servers — the identity boundary promised in Part 1.
- Enforcing the scopes is Part 7; here we establish identity cryptographically.
The one mental shift: authentication answers exactly one question — who is calling? — and it must be answered cryptographically, not with a shared secret. An MCP server is an OAuth resource server: treat every token as untrusted until its signature, issuer, audience, and expiry all check out.
The running example: securing mattrx-analytics
mattrx-analytics runs on Azure Container Apps and is called by internal agents (Mattrx Insights/Help) and approved external assistants. Every caller must prove who they are before a single tool runs. We use Microsoft Entra ID as the identity provider and treat each MCP server as an OAuth 2.1 Resource Server — the model the MCP authorization spec defines. Here's how, with the before that was a liability and the after that holds.
The MCP OAuth flow, end to end
MCP Client (agent host) mattrx-analytics (MCP server) Entra ID
| tools/call (no token) | |
|------------------------------------->| |
| 401 + WWW-Authenticate: resource_metadata |
|<-------------------------------------| |
| GET /.well-known/oauth-protected-resource |
|------------------------------------->| |
| { authorization_servers: [Entra], scopes_supported } |
|<-------------------------------------| |
| OAuth 2.1 (client credentials, or auth code + PKCE for a user) |
|-------------------------------------------------------------------->|
| access token (aud=api://mattrx-analytics, tid, oid, scp) |
|<--------------------------------------------------------------------|
| tools/call + Authorization: Bearer <token> |
|------------------------------------->| validate: sig(JWKS), iss, |
| | aud, exp -> build AiPrincipal |
| result | |
|<-------------------------------------| |
Now each step, with the before and the after.
1. From API keys to OAuth 2.1 bearer tokens
Before
A static, shared, long-lived API key. No identity, no expiry, no scope — and it lives in a dozen config files.
// BEFORE: a shared secret. If it leaks, everyone is you, forever.
app.Use(async (ctx, next) =>
{
if (ctx.Request.Headers["X-Api-Key"] != _config["Mcp:ApiKey"])
{ ctx.Response.StatusCode = 401; return; }
await next();
});
After
OAuth 2.1 bearer tokens issued by Entra and validated on every request.
// AFTER: OAuth 2.1 bearer tokens, validated against Microsoft Entra ID.
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(o =>
{
o.Authority = "https://login.microsoftonline.com/{tenantId}/v2.0"; // Entra
o.TokenValidationParameters = new()
{
ValidAudience = "api://mattrx-analytics", // THIS server (section 4)
ValidateIssuer = true,
ValidateAudience = true,
ValidateLifetime = true,
};
});
Diagnostic: an API key answers "does the caller know the secret?" — not "who is the caller?" It has no identity, never expires, and a leak is a permanent, un-scoped breach. A bearer token carries a verifiable identity, expires in an hour, and can be revoked. On a network, that difference is everything.
Mattrx metric: OAuth tokens (~60-minute TTL) replaced the set of static, long-lived, effectively-unrotatable API keys the bespoke integrations each carried — the one-secret-per-integration sprawl from Part 1 collapsed to one identity boundary.
2. The server is an OAuth Resource Server
Before
The server was a key checker, and clients had to be told out-of-band how to authenticate.
After
The server advertises itself as an OAuth Protected Resource (RFC 9728), so a client can discover which authorization server to get a token from — the model the MCP authorization spec defines.
// Advertise ourselves so clients can discover the auth server and required scopes.
app.MapGet("/.well-known/oauth-protected-resource", () => Results.Json(new
{
resource = "https://mcp.mattrx.internal/analytics",
authorization_servers = new[] { "https://login.microsoftonline.com/{tenantId}/v2.0" },
scopes_supported = new[] { "campaigns:read", "events:read" },
}));
app.MapMcp("/mcp").RequireAuthorization(); // the MCP endpoint now demands a valid token
On a 401 whose WWW-Authenticate header points at that metadata, the client discovers the auth server, runs the OAuth flow, and retries with a token — no manual configuration (this is the client side we sketched in Part 4).
Diagnostic: discovery is what makes MCP auth interoperable. Because the server declares how to authenticate, any spec-compliant client — internal or a partner's — can obtain the right token without a bespoke setup call.
Mattrx metric: publishing resource metadata is what let us open mattrx-analytics to an approved external assistant (from Part 1) — it discovered Entra, got a scoped token, and called in, with zero custom onboarding code.
3. Validate the token — all four checks
Before
A string comparison. Either the key matches or it doesn't; there's nothing to validate.
After
Validate the JWT properly. The bearer middleware checks the signature (against Entra's JWKS), the issuer, the audience, and the lifetime. A token that fails any check is a 401 — there is no partial trust.
Authorization: Bearer <jwt>
|
v
1. Signature valid? (Entra JWKS) -- no -> 401
2. Issuer == our Entra tenant? -- no -> 401
3. Audience == api://mattrx-analytics? -- no -> 401 (confused-deputy guard, section 4)
4. Not expired / not-before satisfied? -- no -> 401
|
v (all pass)
build AiPrincipal { tenant, user, scopes }
Diagnostic: each check stops a real attack — a forged token (signature), a token from the wrong issuer, a token for another service (audience), and a stale or replayed token (lifetime). Skip one and you've left that door open. Validate all four, every time.
Mattrx metric: every request to all three servers passes the four-check gate before a tool runs; there is no code path in which a tool executes on an unvalidated token.
4. Bind the audience — stop the confused deputy
Before
The server accepted any valid Entra token. A token minted for mattrx-reports would happily authenticate against mattrx-analytics.
After
Require the token's audience to be this server. A valid token for another resource is rejected.
// A token issued for another Mattrx API must NOT work here. Audience binding is the
// difference between "a valid token" and "a valid token FOR THIS SERVER."
ValidAudience = "api://mattrx-analytics", // reject aud = api://mattrx-reports, etc.
ValidateAudience = true,
Diagnostic: this is the confused-deputy attack, and it's the single most common serious MCP-auth mistake. A token legitimately issued for service A gets replayed against service B; without audience binding, B trusts it and acts. Bind every token to its intended resource and token-passthrough dies.
Mattrx metric: audience binding means a token scoped for the reports server can never drive an admin tool — each server accepts only tokens minted for its own audience, closing the passthrough path between our three servers.
5. From token to AiPrincipal — identity, not arguments
Before
Even with a key check, tools took the tenant from their arguments — the exact bug from Part 1 that let a hijacked agent act across tenants.
After
Build the AiPrincipal from the validated token claims. Tenant, user, and scopes come from the token — never from a tool's arguments — and this principal flows into every tool (the code-bound tenancy from Part 3).
// Identity comes from the token, cryptographically — not from what the model sends.
public sealed class PrincipalMiddleware(RequestDelegate next)
{
public async Task InvokeAsync(HttpContext ctx, IPrincipalAccessor accessor)
{
var user = ctx.User; // populated by the validated bearer token
accessor.Current = new AiPrincipal(
TenantId: user.FindFirstValue("tid")!, // Entra tenant claim
UserId: user.FindFirstValue("oid") ?? user.FindFirstValue("sub")!,
Scopes: user.FindAll("scp").SelectMany(c => c.Value.Split(' ')).ToHashSet());
await next(ctx);
}
}
Diagnostic: this is the bridge from authentication (who) to authorization (what — Part 7). Because the principal is built from a cryptographically validated token, no tool ever has to — or gets to — trust a tenant id the model passed in. Auth that still trusts arguments is theater.
Mattrx metric: tenant and scopes derived from the token (never arguments) is the foundation of the zero cross-tenant leaks result — the isolation guarantees in later parts all stand on this principal being trustworthy.
6. stdio vs HTTP, and M2M vs user-delegated
Before
The same auth (or none) everywhere, regardless of transport or who was actually acting.
After
Match the mechanism to the situation:
- stdio (local): no network is crossed, so no OAuth — the subprocess runs under the host's own trust. Don't bolt tokens onto a local pipe.
- HTTP (remote): OAuth, always.
- Machine-to-machine (client credentials): the agent acts as itself — a background job, a service.
- User-delegated (authorization code + PKCE): the agent acts on behalf of a signed-in user; the token carries the user's identity and consented scopes.
// M2M: the Insights service authenticates as itself.
var token = await credential.GetTokenAsync(
new(["api://mattrx-analytics/.default"]), ct); // Entra client credentials
// User-delegated: the token carries the END USER's identity + consented scopes
// (authorization code + PKCE), so per-tenant / per-user scoping reflects the real person.
Diagnostic: choose by asking "who is acting?" A nightly report job acts as itself → client credentials. An assistant answering a signed-in user must carry that user's delegated token, or your per-user scoping is a guess rather than a fact.
Mattrx metric: internal batch agents use client credentials; the interactive assistant uses user-delegated tokens — so a user only ever sees data their own delegated token is scoped to, enforced by Entra rather than assumed by us.
The numbers, in one place
| Concern | API key (before) | OAuth 2.1 + Entra (after) |
|---|---|---|
| Credential lifetime | effectively forever | ~60 minutes |
| Identity in the call | none | tenant + user + scopes |
| Confused-deputy attack | works | blocked (audience-bound) |
| Secret sprawl | one key per integration | one identity boundary |
| Cross-tenant leaks | possible | 0 (tenant from token) |
| External caller onboarding | bespoke | self-discovered (resource metadata) |
MCP authentication checklist
- Treat each MCP server as an OAuth 2.1 Resource Server; require auth on the MCP endpoint.
- Publish Protected Resource Metadata so clients discover the authorization server.
- Validate signature (JWKS), issuer, audience, and lifetime on every token.
- Bind the audience to this server — reject tokens minted for anything else.
- Build the principal from validated claims; never take tenant/identity from arguments.
- Use client credentials for agent-as-itself, auth code + PKCE for agent-on-behalf-of-user.
- Keep tokens short-lived; refresh; never place them in URLs or query strings.
- Skip network auth for local stdio — but use OAuth for anything on a network.
The honest stuff: pitfalls and when to relax
- Local stdio dev tool. No network is crossed — don't wrap a subprocess in OAuth. Auth is for network boundaries.
- "Static API key for now." It leaks, never rotates, and carries no identity. If it's reachable over a network, it needs OAuth — "for now" becomes forever.
- Skipping audience validation. The most common and most dangerous mistake — it's the confused-deputy hole. Always bind
aud. - Long-lived tokens. A stolen one-year token is a one-year breach. Short TTL + refresh.
- Tokens in URLs or query strings. They end up in access logs and browser history. Authorization header only.
- Rolling your own token format. Custom JWT signing is a footgun. Use OAuth 2.1 and a real IdP (Entra).
- Trusting the tenant from arguments anyway. Even with perfect auth, if a tool reads the tenant from its args, auth is decorative. Derive it from the token.
The model to carry forward
Authentication is one question, answered cryptographically: who is calling? An MCP server is an OAuth 2.1 resource server — it validates a token's signature, issuer, audience, and expiry, then builds identity from the claims. Everything that follows — authorization, tenancy, audit, isolation — stands on that identity being real. Get it wrong and the rest is built on sand.
Three habits that keep MCP auth sound:
- Be a resource server, not a key checker. OAuth 2.1 + a real IdP, discoverable via resource metadata.
- Validate all four, and bind the audience. Signature, issuer, audience, expiry — and the audience must be this server.
- Derive identity from the token, never from arguments. Tenant and scopes come from validated claims, full stop.
In Part 7 we take the identity we just established and decide what it's allowed to do: scoped authorization for MCP tools — least privilege for agents.
Continue the series — MCP Deep Dive
- Why Model Context Protocol Kills Integration Glue Code for Good
- Inside the MCP Architecture: Hosts, Clients, and Servers
- Build a Production-Grade MCP Server From Scratch
- Build an MCP Client That Connects to Any Tool (and Any Model)
- Custom MCP Tools Your AI Agents Can Actually Trust
- MCP Authentication With OAuth and Entra ID, Done Right (you are here)
- Scoped Authorization for MCP Tools (Least Privilege for Agents)
- Securing MCP Against Prompt Injection and Tool Abuse
- Streaming and Long-Running Tools Over MCP
- Debugging and Observability for MCP in Production
- Rolling MCP Out Across the Enterprise
- Building MCP Servers in C# and .NET 9
- Hosting MCP on Azure at Real Scale
- Wiring MCP Into OpenAI and Agent Frameworks
- Running MCP in Production — Lessons From Mattrx
Further reading
- MCP Deep Dive, Part 7: Scoped Authorization for MCP Tools (Least Privilege for Agents)
- MCP Deep Dive, Part 8: Securing MCP Against Prompt Injection and Tool Abuse
- Enterprise AI Security: 7 Attacks on Your LLM App, and the Layer That Stops Them
Wiring OAuth into your MCP servers and want a second pair of eyes on token validation or audiences? I'm always happy to compare notes — reach me at randhir.jassal@gmail.com.
Get the next issue
A short, curated email with the newest posts and questions.