MCP

Securing a Remote MCP Server

Lock down a remote C# MCP server with ASP.NET Core: JWT and OAuth metadata, role-based tools, per-user data access, rate limits and an audit trail.

5 min readBurak Emre Kadan
All posts
Securing a Remote MCP Server: an MCP client connected to an MCP server through a lock, with authentication, authorization, transport security and best practices.

In the tenth post I said that an MCP server on a network without authentication is an API anyone can call, with tools designed to be easy to call. This post is about not shipping that.

The good part: a remote MCP server in C# is an ASP.NET Core app. Everything you know about securing APIs still applies. There are just a few MCP-specific pieces, and a few mistakes that are easier to make when the caller is a model.

The whole setup first

Here's a Program.cs for the support desk server with every layer in place. We'll go through it piece by piece:

using System.Threading.RateLimiting;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using ModelContextProtocol.AspNetCore.Authentication;

var builder = WebApplication.CreateBuilder(args);

var serverUrl = builder.Configuration["Mcp:ServerUrl"]!;   // e.g. https://support.example.com/mcp
var authority = builder.Configuration["Auth:Authority"]!;  // your identity provider

builder.Services
    .AddAuthentication(options =>
    {
        options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
        options.DefaultChallengeScheme = McpAuthenticationDefaults.AuthenticationScheme;
    })
    .AddJwtBearer(options =>
    {
        options.Authority = authority;
        options.Audience = serverUrl; // only accept tokens issued for this server
    })
    .AddMcp(options =>
    {
        options.ResourceMetadata = new()
        {
            Resource = new Uri(serverUrl),
            AuthorizationServers = { new Uri(authority) },
            ScopesSupported = ["supportdesk:tools"]
        };
    });

builder.Services.AddAuthorization();

builder.Services.AddRateLimiter(options =>
    options.AddPolicy("per-caller", context =>
        RateLimitPartition.GetFixedWindowLimiter(
            context.User.Identity?.Name ?? "anonymous",
            _ => new FixedWindowRateLimiterOptions
            {
                PermitLimit = 60,
                Window = TimeSpan.FromMinutes(1)
            })));

builder.Services.AddSupportDesk();
builder.Services
    .AddMcpServer()
    .WithHttpTransport()
    .AddAuthorizationFilters()
    .AddSupportDeskTools();

var app = builder.Build();

app.UseAuthentication();
app.UseAuthorization();
app.UseRateLimiter();

app.MapMcp("/mcp")
    .RequireAuthorization()
    .RequireRateLimiting("per-caller");

app.Run();

Layer 1: nobody gets in without a token

RequireAuthorization() on MapMcp is the line that matters most. MapMcp returns a normal endpoint builder, so it works exactly like on any minimal API. Without it, everything else in this post is decoration.

Tokens are validated with the standard JWT bearer handler. Note the Audience: the server only accepts tokens that were issued for it. A token your user got for some other API must not work here, and your server must not forward the user's token to other APIs either. If a tool calls a downstream service, it uses its own credentials for that service.

Layer 2: tell clients where to log in

That's what AddMcp adds. When a client calls without a token, the server answers 401 and points to a small metadata document saying which authorization server issues tokens for it and which scopes exist. MCP clients that support OAuth use this to send the user through the login flow on their own. Without it, every user has to configure a token by hand.

The setting for the challenge scheme decides this: authenticate with JWT bearer, but challenge with the MCP scheme, so the 401 includes the pointer to the metadata.

Layer 3: tools by role

Authentication says who is calling. Authorization says what they may do. With AddAuthorizationFilters(), the normal ASP.NET Core attributes work on tool classes and methods:

[McpServerToolType]
[Authorize(Roles = "SupportAgent,Admin")]
public sealed class TicketAdminTools(TicketService tickets)
{
    // resolve_ticket, draft_reply, send_reply ...
}

A caller without the role doesn't just get an error when calling these tools. The tools don't appear in their tool list at all. That's the best possible outcome: the model can't misuse a tool it doesn't know exists. It's the same idea as the eighth post, where we split read and admin tools, now enforced per user instead of per deployment.

Layer 4: the user's identity comes from the token, not from the model

This is the MCP-specific mistake I see most often. A tool like this looks reasonable:

// Don't do this
public Task<IReadOnlyList<TicketSummary>> ListMyTickets(string assigneeEmail) => ...

The model fills assigneeEmail. Usually with the right value. But a model can be confused, or manipulated by text inside a ticket, into passing someone else's email. Now one user reads another user's tickets, and your role checks didn't help, because the user was allowed to call the tool.

The fix is to take the identity from the authenticated request, never from a parameter:

[McpServerTool(Name = "list_my_tickets", ReadOnly = true)]
[Description("Lists open tickets assigned to the current user.")]
public Task<IReadOnlyList<TicketSummary>> ListMyTickets(
    ClaimsPrincipal user,
    CancellationToken cancellationToken)
    => tickets.ListAssignedToAsync(
        user.FindFirstValue(ClaimTypes.NameIdentifier)!,
        cancellationToken);

The SDK fills the ClaimsPrincipal from the request, just like the CancellationToken. It doesn't appear in the schema, so the model can't touch it. The same rule applies to every tool that returns data: get_ticket should check that the caller's team may see that ticket, and fail with a clear error, as in the ninth post, if not.

Roles decide which tools someone can call. Your data access code decides which records they can see. You need both.

Layer 5: limits and a paper trail

Models call tools in loops. A bug in a prompt or a stuck agent can call the same tool hundreds of times in a minute. The rate limiter partitions by caller, so one runaway agent slows down only its own user.

For the audit trail, the 2026-07-28 spec gives you a gift: clients now send the MCP method and tool name as HTTP headers. You can log them without parsing request bodies:

app.Use(async (context, next) =>
{
    await next();

    if (context.Request.Path.StartsWithSegments("/mcp"))
    {
        app.Logger.LogInformation(
            "MCP {Method} {Tool} by {User} -> {Status}",
            context.Request.Headers["Mcp-Method"].ToString(),
            context.Request.Headers["Mcp-Name"].ToString(),
            context.User.Identity?.Name ?? "anonymous",
            context.Response.StatusCode);
    }
});

Put it after UseAuthentication so the user is known. Two things to keep in mind: older clients may not send these headers yet, and a tool that fails with isError still returns HTTP 200, so for tool-level outcomes, log inside your tools as well.

The small stuff that still matters

  • AllowedHosts: set it to your real host name instead of *.
  • CORS: if browser-based clients connect, allow their origins explicitly. Never allow any origin on an endpoint with tools that write.
  • HTTPS only, like any API that carries tokens.

Checklist

  1. MapMcp has RequireAuthorization.
  2. Tokens are validated with your server as the audience, and never passed through.
  3. AddMcp publishes metadata so clients can log in by themselves.
  4. Write and admin tools carry [Authorize] roles.
  5. Identity comes from ClaimsPrincipal, never from a tool parameter.
  6. Data access checks ownership, not just roles.
  7. Rate limits per caller, and a log of who called what.

None of this is exotic. It's ordinary API security, applied to a client that is fast, tireless and sometimes wrong.


Want the full build? Build MCP Servers in C#: Connect AI to Your .NET Apps shows you how to give AI access to your own .NET apps with an MCP server. Or get the weekly tips by email: /newsletter.

NEWSLETTER

Get the next one in your inbox

Weekly .NET + AI tips, the source code from every video, and early access to the course.

You'll get weekly .NET + AI tips. Unsubscribe anytime.

Keep reading

Prompt Injection Through Tool Results: a user asks an agent to show the latest ticket, and the get_ticket tool result contains an injected instruction to reset all users' passwords, with four defenses listed below.
MCP

Prompt Injection Through Tool Results

Text your tools return can carry instructions the model may follow. Why you can't filter that away, and how to design a C# MCP server so it can't do much harm.

5 min readRead