Implement a secure MCP server using OAuth and Entra ID

Model Context Protocol servers are becoming the standard way to expose tools to AI agents, and that raises an obvious question: how do you stop just any client from calling your MCP server? Damien Bod’s walkthrough answers this with a straightforward pattern for ASP.NET Core: secure the MCP server with Microsoft Entra ID delegated tokens, then build an MCP client that acquires those tokens on behalf of a signed in user. This article recreates that pattern, explains why each piece exists, and points out a few things worth thinking about before you take this to production.

The full sample code is available on Damien Bod’s GitHub repository, McpSecurity, and everything below follows that project’s structure.

Why an unsecured MCP server is a problem

An MCP server exposes tools, functions that an AI agent can call to fetch data, run calculations, or trigger actions. If that server has no authentication, any client that can reach the URL can invoke those tools. For a weather lookup tool that might be harmless, but MCP servers are increasingly used to wrap internal APIs, databases, and business logic, so an open endpoint is a real risk.

Entra ID solves this in a familiar way for anyone who has secured a Web API before: the MCP server validates an OAuth access token on every request, and only accepts delegated tokens issued for a specific user, not application only tokens. This matters because delegated tokens tie every tool call back to a real identity, which keeps your audit trail meaningful and stops a compromised service principal from silently exercising MCP tools on its own.

Solution architecture

The setup uses two separate ASP.NET Core applications. The MCP server exposes tools and validates access tokens. The MCP client is a web application that signs the user in through Entra ID, acquires a delegated token for the MCP server’s scope, and uses Semantic Kernel with Azure OpenAI to turn user prompts into tool calls.

High level flow: user, web client, Entra ID, and MCP server
High level flow: user, web client, Entra ID, and MCP server

Two App Registrations are needed in Entra ID: one for the web application (the MCP client) and one for the MCP server (the resource). The web application authenticates using the OpenID Connect authorization code flow with PKCE, then requests a delegated access token scoped to the MCP server’s exposed scope, something like mcp:tools. The MCP server validates that scope claim on every incoming request before it lets a tool run.

Entra ID app registrations and token flow between the client and the MCP server
Entra ID app registrations and token flow between the client and the MCP server

This two app registration pattern is the same one you would use for any client calling a downstream Web API in Entra ID. If you have already secured an ASP.NET Core API with Microsoft.Identity.Web, this will feel familiar. The only MCP specific piece is how the resource metadata and scopes are wired into the MCP server’s authentication options.

Building the secure MCP server

Setting up an MCP server in .NET is genuinely simple once you bring in the ModelContextProtocol.AspNetCore NuGet package. It gives you extension methods to register the server and its transport, and you only need to define the tools you want exposed. Security is layered on top using Microsoft.Identity.Web, the same package used for securing any ASP.NET Core Web API with Entra ID.

The code below wires up Entra ID authentication, configures the MCP server’s resource metadata so clients know which authorization server to use, registers the available tools, and adds a CORS policy since browser based clients may need to reach this endpoint directly.

builder.Services.AddMicrosoftIdentityWebApiAuthentication(builder.Configuration);
var httpMcpServerUrl = builder.Configuration["HttpMcpServerUrl"];
 
builder.Services.AddAuthentication()
.AddMcp(options =>
{
    options.ResourceMetadata = new()
    {
        Resource = new Uri(httpMcpServerUrl),
        ResourceDocumentation = new Uri("https://mcpoauthsecurity-hag0drckepathyb6.westeurope-01.azurewebsites.net/health"),
        //AuthorizationServers = { new Uri(inMemoryOAuthServerUrl) },
        ScopesSupported = ["mcp:tools"],
    };
});
 
builder.Services.AddAuthorization();
 
builder.Services
       .AddMcpServer()
       .WithHttpTransport()
       .WithTools<RandomNumberTools>()
       .WithTools<DateTools>()
       .WithTools<WeatherTools>();
 
// Add CORS for HTTP transport support in browsers
builder.Services.AddCors(options =>
{
    options.AddDefaultPolicy(policy =>
    {
        policy.AllowAnyOrigin()
              .AllowAnyHeader()
              .AllowAnyMethod();
    });
});
 
builder.Services.AddHttpClient();
 
// change to scp or scope if not using magic namespaces from MS
// The scope must be validated as we want to force only delegated access tokens
// The scope is required to only allow access tokens intended for this API
builder.Services.AddAuthorizationBuilder()
  .AddPolicy("mcp_tools", policy =>
        policy.RequireClaim("http://schemas.microsoft.com/identity/claims/scope", "mcp:tools"));

The important line here is the AddPolicy call at the bottom. It requires the scope claim to carry the value mcp:tools. Application only tokens issued through the client credentials flow do not carry a scp claim in the same way, so this policy effectively locks the server to delegated, user context tokens. Skip this check and you allow any client with valid application credentials to call your tools, which defeats the point of tying access to a real user.

With the services registered, the middleware pipeline needs to enforce this policy on the actual MCP endpoint. This is a short addition: enable authentication and authorization as usual, then require the mcp_tools policy specifically on the MCP route.

// Configure the HTTP request pipeline.
app.UseHttpsRedirection();
 
// Enable CORS
app.UseCors();
 
app.MapGet("/health", () => $"Secure MCP server running deployed: UTC: {DateTime.UtcNow}, use /mcp path to use the tools");
 
app.UseAuthentication();
app.UseAuthorization();
 
app.MapMcp("/mcp").RequireAuthorization("mcp_tools");

Notice the health endpoint is left open, which is a reasonable choice since it does not expose any tool functionality, only a liveness check. The MCP endpoint itself is mapped to /mcp and requires the mcp_tools policy, so any request without a valid delegated token carrying the right scope gets rejected before it reaches your tool code.

Building the MCP client in ASP.NET Core

With the server locked down, the client needs to sign the user in, get a delegated token for the MCP server’s scope, and use that token on every call. The web application in the sample uses the standard OpenID Connect code flow with PKCE through Microsoft.Identity.Web, which is the same pattern you would use to secure any Razor Pages or MVC application with Entra ID.

The key addition compared to a normal web app is the call to EnableTokenAcquisitionToCallDownstreamApi, which tells Microsoft.Identity.Web to acquire tokens for the MCP server’s scope and cache them for the signed in user.

var builder = WebApplication.CreateBuilder(args);
 
builder.Services.AddAuthentication(OpenIdConnectDefaults.AuthenticationScheme)
    .AddMicrosoftIdentityWebApp(builder.Configuration.GetSection("AzureAd"))
    .EnableTokenAcquisitionToCallDownstreamApi(["api://96b0f495-3b65-4c8f-a0c6-c3767c3365ed/mcp:tools"])
    .AddInMemoryTokenCaches();
 
builder.Services.AddAuthorization(options =>
{
    // By default, all incoming requests will be authorized according to the default policy.
    options.FallbackPolicy = options.DefaultPolicy;
});
 
builder.Services.AddRazorPages()
    .AddMicrosoftIdentityUI();
 
builder.Services.AddScoped<ChatService>();
 
var app = builder.Build();
 
// Configure the HTTP request pipeline.
if (!app.Environment.IsDevelopment())
{
    app.UseExceptionHandler("/Error");
    // The default HSTS value is 30 days. You may want to change this for production scenarios, see https://aka.ms/aspnetcore-hsts.
    app.UseHsts();
}
 
app.UseHttpsRedirection();
 
app.UseRouting();
 
app.UseAuthorization();
 
app.MapStaticAssets();
app.MapRazorPages()
   .WithStaticAssets();
app.MapControllers();
 
app.Run();

The scope api://96b0f495-3b65-4c8f-a0c6-c3767c3365ed/mcp:tools in this snippet is specific to Damien Bod’s sample App Registration, so you would replace the GUID with your own MCP server’s application ID URI. Once the user authenticates and this token is cached, the InMemoryTokenCaches store makes it available to any service that needs to call the MCP server on the user’s behalf, without asking the user to log in again for every request.

The actual agent logic lives in a ChatService class, which uses the Microsoft.SemanticKernel NuGet package to run prompts through Azure OpenAI and lets the model call MCP tools when needed. It pulls the delegated access token using ITokenAcquisition, attaches it as a Bearer token on an HttpClient, and imports the MCP server’s tools into the Semantic Kernel instance so the model can invoke them.

public class ChatService
{
    private readonly IConfiguration _configuration;
    private readonly ElicitationCoordinator _elicitationCoordinator;
    private Kernel _kernel;
    private IMcpClient _mcpClient = null!;
    private bool _initialized;
    private ApprovalMode _mode = ApprovalMode.Manual;
    private readonly ITokenAcquisition _tokenAcquisition;
 
    private PromptingService? _promptingService;
 
    public ChatService(IConfiguration configuration, ElicitationCoordinator elicitationCoordinator, ITokenAcquisition tokenAcquisition)
    {
        _configuration = configuration;
        _elicitationCoordinator = elicitationCoordinator;
        var config = new ConfigurationBuilder()
            .AddUserSecrets<Program>()
            .Build();
        _kernel = SemanticKernelHelper.GetKernel(config);
        _tokenAcquisition = tokenAcquisition;
    }
 
    public void SetMode(ApprovalMode mode)
    {
        if (_mode != mode)
        {
            _initialized = false;
            _mode = mode;
        }
    }
 
    public async Task EnsureSetupAsync(IHttpClientFactory clientFactory)
    {
        if (_initialized) return;
 
        var accessToken = await _tokenAcquisition
            .GetAccessTokenForUserAsync([_configuration["McpScope"]!]);
 
        _mcpClient = await McpClientFactory.CreateAsync(CreateMcpTransport(clientFactory, accessToken), GetMcpOptions());
        await _kernel.ImportMcpClientToolsAsync(_mcpClient);
 
        _promptingService = new PromptingService(_kernel, autoInvoke: _mode == ApprovalMode.Elicitation);
        _initialized = true;
    }
 
    private McpClientOptions? GetMcpOptions()
    {
        return _mode == ApprovalMode.Elicitation ? new McpClientOptions
        {
            ClientInfo = new() { Name = "WebElicitationClient", Version = "1.0.0" },
            Capabilities = new() { Elicitation = new() { ElicitationHandler = HandleElicitationAsync } }
        } : null;
    }
 
    // Inlined former WebElicitationHandler logic
    private ValueTask<ElicitResult> HandleElicitationAsync(ElicitRequestParams? requestParams, CancellationToken token)
    {
        return _elicitationCoordinator.HandleAsync(requestParams, token);
    }
 
    private IClientTransport CreateMcpTransport(IHttpClientFactory clientFactory, string accessToken)
    {
        var httpClient = clientFactory.CreateClient();
        httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
        var httpMcpServerUrl = _configuration["HttpMcpServerUrl"] ?? throw new ArgumentNullException("Configuration missing for HttpMcpServerUrl");
        return new SseClientTransport(new() { Endpoint = new Uri(httpMcpServerUrl), Name = "Secure Client" }, httpClient);
    }
 
    private PromptingService Handler => _promptingService ?? throw new InvalidOperationException("Service not initialized");
 
    public Task<ChatResponse> BeginChatAsync(string userKey, string prompt) => Handler.BeginAsync(userKey, prompt);
    public Task<ChatResponse> ApproveFunctionAsync(string userKey, string functionId) => Handler.ApproveAsync(userKey, functionId);
    public Task<ChatResponse> DeclineFunctionAsync(string userKey, string functionId) => Handler.DeclineAsync(userKey, functionId);
}

A few details in this class are worth calling out. EnsureSetupAsync only runs the token acquisition and MCP client setup once per mode, using the _initialized flag, so switching approval modes forces a fresh connection rather than reusing stale credentials. The ApprovalMode enum with Manual and Elicitation values suggests the sample supports both a workflow where the user approves each tool call and one where the agent can invoke tools automatically, which is a sensible safety valve when you are exposing tools that can change data, not just read it. If you are adapting this for your own project, the elicitation handling and approval flow is the part most worth studying, since automatically letting an LLM call arbitrary tools without any human checkpoint is where most production incidents in this space start.

The web UI itself is a plain Razor Page. The handler below sets the approval mode, ensures the MCP client and Semantic Kernel are wired up, then sends the prompt and stores the result for rendering.

 public async Task<IActionResult> OnPostAsync()
 {
        if (!ModelState.IsValid)
        {
            return OnGet();
        }
 
        _chatService.SetMode(SelectedMode);
        await _chatService.EnsureSetupAsync(_clientFactory);
 
        // Begin a fresh chat with the prompt
        var response = await _chatService.BeginChatAsync(GetUserKey(), Prompt);
        PromptResults = response.FinalAnswer;
        PendingFunctions = response.PendingFunctions;
        return Page();
 }

This handler is intentionally thin. All the token handling, MCP client setup, and Semantic Kernel orchestration stay inside ChatService, which keeps the Razor Page focused on request and response plumbing. Once this is running, a signed in user can type a prompt in the browser and the agent will call MCP tools on the secured server as needed to answer it.

The web client sending a prompt that triggers an MCP tool call through the secured server
The web client sending a prompt that triggers an MCP tool call through the secured server

What works well, and where the gaps are

This delegated token approach works solidly for enterprise scenarios where every MCP tool call needs to trace back to a real user identity, and where you control both the client and server App Registrations. Entra ID handles the heavy lifting of token issuance and validation, and Microsoft.Identity.Web removes most of the boilerplate you would otherwise write by hand for token acquisition and caching.

The approach has real limits worth knowing before you commit to it. OAuth 2.0 Dynamic Client Registration is not used here, so every MCP client needs a pre-provisioned App Registration, which does not scale well if you expect third party or public clients to connect the way a fully open MCP ecosystem might. Entra ID also assumes an enterprise identity model, so this pattern fits internal tools and B2B integrations better than a consumer facing MCP server with self service sign up.

If you are building something that needs to serve many independent tenants or external developers, look at the OAuth 2.0 Authorization Server Metadata and Dynamic Client Registration specifications the MCP authorization spec references, since those are built for exactly that kind of open registration flow. For an internal enterprise scenario like this one, sticking with pre-registered Entra ID applications and validating the scope claim strictly, as this sample does, is the simpler and more predictable choice.

Production considerations before you ship this

A few things to check before running this pattern in production. First, confirm the scope validation policy is actually enforced on every MCP route, not just the default one, since it is easy to add a new tool endpoint later and forget to apply the same authorization policy. Second, review the CORS policy: AllowAnyOrigin combined with AllowAnyHeader and AllowAnyMethod is convenient for a demo but far too permissive for a production API, so scope it down to the specific origins that need browser access.

Third, think through the approval mode question early. Letting an agent auto invoke MCP tools without any human checkpoint is fine for read only tools like date or weather lookups, but risky for anything that writes data or triggers a downstream action. The Manual and Elicitation modes in this sample hint at that distinction, and it is worth keeping that separation explicit in your own tool design rather than treating all tools as equally safe to auto invoke.

Leave a Reply

Discover more from Behind the Stack

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

Continue reading