DEV Community

kirandeepjassal-crypto
kirandeepjassal-crypto

Posted on • Originally published at prepstack.co.in

MCP Deep Dive, Part 6: MCP Authentication With OAuth and Entra ID, Done Right

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();
});
Enter fullscreen mode Exit fullscreen mode
// 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,
        };
    });
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 }
Enter fullscreen mode Exit fullscreen mode

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,
Enter fullscreen mode Exit fullscreen mode

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);
    }
}
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

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)