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; Part 8 is abuse.
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 |
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.
1. From API keys to OAuth 2.1 bearer tokens
A static, shared, long-lived API key has 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, 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,
};
});
An API key answers "does the caller know the secret?" — not "who is the caller?" A bearer token carries a verifiable identity, expires in an hour, and can be revoked. OAuth tokens (~60-minute TTL) replaced the pile of static, unrotatable keys the bespoke integrations each carried.
2. The server is an OAuth Resource Server
The server advertises itself as an OAuth Protected Resource (RFC 9728), so a client can discover which authorization server to get a token from.
// 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. Discovery is what makes MCP auth interoperable: it let us open the server to an approved external assistant with zero custom onboarding code.
3. Validate the token — all four checks
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 }
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.
4. Bind the audience — stop the confused deputy
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,
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.
5. From token to AiPrincipal — identity, not arguments
Build the principal from the validated token claims. Tenant, user, and scopes come from the token — never from a tool's arguments.
// 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);
}
}
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 gets to trust a tenant id the model passed in. Auth that still trusts arguments is theater.
6. stdio vs HTTP, and M2M vs user-delegated
Match the mechanism to the situation:
- stdio (local): no network is crossed, so no OAuth — the subprocess runs under the host's own trust.
- 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.
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.
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) |
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.
- 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.
Originally published on PrepStack. Wiring OAuth into your MCP servers and want a second pair of eyes on token validation or audiences? Reach me at randhir.jassal[at]gmail.com.
Top comments (0)