Implement a secure MCP OAuth desktop client using OAuth and Entra ID

Model Context Protocol (MCP) servers are quickly becoming a standard way to expose tools to AI agents, and once you put a real MCP server behind authentication, the desktop client side gets interesting. A recent post from Damien Bod walks through exactly this scenario: a .NET console application that signs a user in through Microsoft Entra ID and then calls tools on a secured MCP server. The pattern matters because a desktop app cannot protect a client secret the way a web backend can, so the entire authentication flow has to be designed around that constraint.

The setup has two moving parts. An MCP server built with ASP.NET Core, covered in an earlier post, accepts only delegated access tokens issued by Entra ID for a specific tenant. A console client signs the user in, requests a token scoped for that server, and attaches it to every outgoing call, with an Azure OpenAI piece added on top to drive the agent loop.

Why the client has to be public

Entra ID app registrations come in two flavours: confidential clients that can hold a secret, and public clients that cannot. A console application shipped to a user’s machine falls firmly into the second category, because anyone with access to the binary or the machine can extract a hardcoded secret. So this client is registered as a public client and authenticates using the OpenID Connect authorization code flow with PKCE. PKCE, Proof Key for Code Exchange, replaces the client secret with a code verifier generated at runtime, which protects the exchange even though there is nothing confidential to keep on the client side.

This choice rules out client-credentials, app-to-app, flows completely for this client, because there is no safe place to store an application secret on a desktop machine. Every access token the client obtains is therefore a delegated token, tied to whichever user signed in through the browser. That has a real consequence later: this client can never call the MCP server unattended, only while representing a signed-in person.

User to Agent to MCP Server: the delegated flow this client implements.
User to Agent to MCP Server: the delegated flow this client implements.

What the MCP server expects

The MCP server side is not new here, it is the same ASP.NET Core server from Damien Bod’s earlier post on securing an MCP server with OAuth and Entra ID. It validates the incoming bearer token, checks that it was issued by the correct tenant, and requires the mcp:tools scp claim on the token before allowing access to any tool. Application-only tokens are rejected outright, so a client that somehow obtained an app-only token would not get anywhere.

Signing the user in with MSAL

The client uses the Microsoft.Identity.Client NuGet package, MSAL.NET, to implement the public client authentication. The SignInUserAndGetTokenUsingMSAL method first tries AcquireTokenSilent against any cached account, and only falls back to an interactive browser prompt when that fails with an MsalUiRequiredException. This silent-first, interactive-fallback pattern is standard MSAL practice, and it avoids popping up a browser window every single time the app runs.

private static async Task<string> SignInUserAndGetTokenUsingMSAL(
    PublicClientApplicationOptions configuration, string[] scopes)
{
    string authority = string.Concat(configuration.Instance, configuration.TenantId);
 
    // Initialize the MSAL library by building a public client application
    application = PublicClientApplicationBuilder.Create(configuration.ClientId)
                    .WithAuthority(authority)
                    .WithDefaultRedirectUri()
                    .Build();
 
    AuthenticationResult result;
    try
    {
        var accounts = await application.GetAccountsAsync();
        result = await application.AcquireTokenSilent(scopes, accounts.FirstOrDefault())
         .ExecuteAsync();
    }
    catch (MsalUiRequiredException ex)
    {
        result = await application.AcquireTokenInteractive(scopes)
         .WithClaims(ex.Claims)
         .ExecuteAsync();
    }
 
    return result.AccessToken;
}

A few things are worth noticing here. WithDefaultRedirectUri() configures MSAL to use its built-in loopback redirect, which spins up a temporary listener on localhost and captures the authorization code once the browser redirects back, so there is no need to register a custom redirect URI for local development. The catch block for MsalUiRequiredException also forwards ex.Claims into the interactive call, which matters if Conditional Access policies demand extra claims such as MFA, otherwise the interactive prompt would silently fail to satisfy the policy. One thing this demo does not do is configure a persistent token cache serializer, so the account cache lives only in memory for the process lifetime, meaning every fresh run of the console app triggers a new interactive sign-in even if the previous token was still valid. That is fine for a demo but worth fixing before shipping anything a real user will run daily.

Attaching the token to the MCP transport

Once the client has an access token, it needs to get onto every request the MCP transport sends. CreateMcpTransportAsync binds configuration into PublicClientApplicationOptions, defines the scope as api://app-id/mcp:tools, and calls the sign-in method to get a token for that scope. It then sets the Authorization header on a shared HttpClient before handing that HttpClient to an SseClientTransport, which is how the MCP C# SDK talks to a remote server over Server-Sent Events.

private static PublicClientApplicationOptions? appConfiguration = null;
 
// The MSAL Public client app
private static IPublicClientApplication? application;
 
public static async Task<IClientTransport> CreateMcpTransportAsync(HttpClient httpClient, IConfigurationRoot configuration)
{
    appConfiguration = configuration.Get<PublicClientApplicationOptions>();
    string[] scopes = ["api://96b0f495-3b65-4c8f-a0c6-c3767c3365ed/mcp:tools"];
 
    // Sign-in user using MSAL and obtain an access token for MS Graph
    var accessToken = await SignInUserAndGetTokenUsingMSAL(appConfiguration!, scopes);
 
    httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
 
    var httpMcpServer = configuration["HttpMcpServerUrl"];
    var transport = new SseClientTransport(new()
    {
        Endpoint = new Uri(httpMcpServer!),
        Name = "MCP Desktop Client",
    }, httpClient);
 
    return transport;
}

Setting the Authorization header directly on the HttpClient instance, rather than per request, means every call the transport later makes on that client automatically carries the bearer token. That is convenient but it is also a place production code needs more care: MSAL’s AcquireTokenSilent handles renewing an expired access token on the next call, but nothing here re-checks the header before a long-lived transport reuses it, so a long-running client session could end up sending a stale token. It would be worth adding a delegating handler that refreshes the token before each call rather than trusting a header set once at startup. The scope is also hardcoded to one specific application ID, so pointing this client at a different tenant or a different MCP server means updating the scope string and the app registration together, not just the server URL.

What you see when you run it

When the console app starts, it opens the default browser straight to the Entra ID sign-in page, exactly the flow you would expect from an OpenID Connect authorization code exchange. The screenshot below shows this in practice, the familiar Microsoft sign-in form appearing in a new browser tab while the console app waits on its loopback listener. Once the user completes sign-in, control returns to the console app with an authorization code that MSAL exchanges for the access token.

The console app opening the Entra ID sign-in page in the default browser.
The console app opening the Entra ID sign-in page in the default browser.

Why Dynamic Client Registration was deliberately left out

The article makes a point of saying Dynamic Client Registration, DCR, is not used here and should not be used in this kind of setup. DCR lets a client register itself with an authorization server at runtime, without a human pre-approving the app registration, which sounds convenient for MCP clients that want to connect to arbitrary servers. The problem is trust: if any client can self-register, the server has no real way to know what is calling it or whether that client should be trusted with delegated access to a user’s data. Pre-registering clients, as this setup does, keeps a human in the loop deciding which applications are allowed to exist in the first place, a reasonable trade-off against the convenience DCR promises.

Hardening beyond this demo

Bearer tokens, once issued, work for whoever holds them, which is the main limitation of this setup as it stands. If a token is exfiltrated from a compromised desktop, an attacker can replay it against the MCP server until it expires, and nothing here binds the token to the specific machine or process that requested it. Adding DPoP, Demonstrating Proof of Possession, or mutual TLS would bind each token to a private key the client holds, so a stolen token alone would not be enough to call the server. That is a sensible next step for anyone taking this pattern into production.

It is worth comparing this loopback-redirect flow against other options MSAL supports. Device code flow is the better choice for headless environments or remote terminals where no local browser is available, at the cost of a slightly clunkier experience of typing a code on a second device. On Windows, broker-based authentication through the Web Account Manager gives single sign-on against the machine’s existing Entra ID account and stronger token protection than a plain public client, and is worth considering when the target users are all on managed Windows devices.

This pattern fits well anywhere an MCP client is run directly by a human at a keyboard: an internal developer tool, an admin utility, or an AI coding assistant plugged into a local agent. It fits poorly for background jobs, scheduled tasks, or service-to-service calls, because there is deliberately no application-only path here. For those scenarios, a separate app registration using client-credentials with its own tightly scoped permissions is the more honest solution, rather than forcing a delegated flow to work unattended.

Leave a Reply

Discover more from Behind the Stack

Subscribe now to keep reading and get access to the full archive.

Continue reading