Skip to main content

Command Palette

Search for a command to run...

Securing an MCP Server in ASP.NET Core: OAuth, Scopes and Tool Authorization

Updated
10 min readView as Markdown
Securing an MCP Server in ASP.NET Core: OAuth, Scopes and Tool Authorization

The thing that makes MCP servers so quick to build is also what makes them dangerous. You decorate a method, the SDK generates a tool definition, and a language model somewhere can now invoke it. That method usually runs with your application's credentials against your production database. Securing an MCP server in ASP.NET Core matters because the default shape of the thing you just built is a remotely callable API over your data, described in a machine-readable format specifically so that automated clients can discover and call it.

I've reviewed MCP servers that were mapped with no authentication at all, on the reasoning that "it's only used by our internal agent". The tools returned customer records by id. That is not an MCP problem; it is the oldest authorization problem there is, arriving through a new door. What follows is the threat model and the ASP.NET Core configuration that closes it. The complete secured server, with per-tool policies and the audit trail, is on Patreon.

Auth is the part of MCP that the quickstarts skip and production immediately demands. Chapter 14 of AI-Powered .NET APIs builds an MCP server over a real ASP.NET Core API with the HTTP transport and covers authentication and publishing as part of the same chapter, rather than leaving it as an exercise.

AI-Powered .NET APIs

The Threat: Your Tools Run as You, Not as the Caller

An MCP tool is a method on your server. When it executes, it holds whatever privileges your application holds. The caller is a model acting on behalf of some user, and unless you deliberately connect those two facts, every caller effectively gets your application's full access.

That produces four distinct threats worth naming separately:

  • Broken object-level authorization. A tool that takes an id and returns the record is an enumeration endpoint. The model will happily supply an id the user was never entitled to, because it has no concept of entitlement. This is the same class of flaw we covered in preventing BOLA in ASP.NET Core APIs.

  • Broken function-level authorization. Every tool is exposed to every client that completes a handshake. If one tool issues refunds and another looks up order status, both are equally reachable unless you say otherwise. Our guide to preventing BFLA applies directly.

  • The confused deputy. Your server is a trusted component holding real credentials, being told what to do by a model that is being told what to do by text. A poisoned document in a RAG corpus or a hostile support ticket can produce a tool call the user never asked for.

  • Token passthrough. A client presents a token issued for some other service, and a server that only checks the signature accepts it. Now anyone holding a token for any service in your estate can drive your tools.

Why the Usual Reassurances Do Not Hold

Three arguments come up in review, and none of them survive contact with production.

"It is only reachable inside the network." MCP servers get published. The whole point of the HTTP transport is remote access, and the deployment that was internal in March is behind a gateway in June.

"Only our own agent calls it." The agent is driven by a model that is driven by text your users control. Treat every tool call as attacker-influenced, because indirect prompt injection means it can be.

"The tool descriptions do not mention the dangerous parameters." Descriptions are hints for the model, not access control. The wire protocol accepts whatever the schema permits.

The Vulnerable Pattern

Here is the shape that ships by accident:

// Vulnerable: no authentication on the endpoint, no authorization in the tool
builder.Services.AddMcpServer().WithHttpTransport().WithToolsFromAssembly();
app.MapMcp();

[McpServerTool, Description("Get a customer by id.")]
public static async Task<Customer?> GetCustomer(int customerId, AppDbContext db)
    => await db.Customers.FindAsync(customerId);

Two independent failures. The transport accepts anonymous connections, and the tool applies no authorization even if it had an identity to apply it to. Fixing only the first gives you an authenticated enumeration endpoint, which is barely an improvement. If you have not built one of these yet, our walkthrough on building an MCP server in ASP.NET Core covers the transport and tool wiring this section assumes.

Securing It: The MCP Server Is a Resource Server

The MCP authorization specification models the server as an OAuth 2.1 resource server, not an authorization server. You do not issue tokens. You validate them, and you advertise where clients should go to get one.

Step 1 - require authentication on the transport. Standard ASP.NET Core, because MCP over HTTP is just an endpoint:

app.MapMcp().RequireAuthorization();

Step 2 - validate the audience, not just the signature. This is the control that stops token passthrough, and it is the one most often left at defaults:

options.TokenValidationParameters = new TokenValidationParameters
{
    ValidateAudience = true,
    ValidAudience    = "https://mcp.example.com",   // this server, specifically
    ValidateIssuer   = true,
    ValidIssuer      = "https://login.example.com"
};

A token minted for your web API must not be accepted by your MCP server. Give the MCP server its own audience identifier and reject everything else.

Step 3 - publish protected resource metadata. So compliant clients can discover your authorization server rather than being configured by hand, per RFC 9728. The C# SDK supports OAuth 2.0 Protected Resource Metadata and emits the corresponding WWW-Authenticate challenge on a 401, which is how a client learns where to authenticate. This is what makes the flow work without out-of-band setup.

Step 4 - authorize per tool, not per server. The SDK honours [Authorize] and [AllowAnonymous] on tools, prompts, and resources, so scope them individually:

[McpServerTool, Authorize(Policy = "orders:read")]
public static Task<OrderSummary> GetOrder(...)

[McpServerTool, Authorize(Policy = "orders:refund")]
public static Task<RefundResult> IssueRefund(...)

Map those policies to OAuth scopes so the token itself carries the entitlement. A read-only agent then holds a token that cannot issue refunds, regardless of what any model decides to call.

Step 5 - apply the caller's authorization inside the tool. This is the step that actually closes the object-level hole, and no amount of endpoint configuration substitutes for it:

[McpServerTool, Authorize(Policy = "orders:read")]
public static async Task<Order?> GetOrder(
    int orderId, AppDbContext db, IHttpContextAccessor http)
{
    var tenantId = http.HttpContext?.User.FindFirst("tenant_id")?.Value;
    return await db.Orders
        .SingleOrDefaultAsync(o => o.Id == orderId && o.TenantId == tenantId);
}

The rule to hold onto: an MCP tool must be exactly as restricted as the equivalent REST endpoint. If GET /orders/{id} filters by tenant and checks resource ownership, the tool must do the same, ideally by calling the same application service rather than reaching for the DbContext directly.

Destructive Tools Need a Human

Authorization answers "is this caller allowed to do this". It does not answer "did the user actually ask for this". For anything irreversible - refunds, deletions, outbound messages, spend - require an explicit confirmation step rather than letting a tool call complete on the model's say-so.

The practical pattern is a two-phase tool: one that returns a description of what would happen and a short-lived confirmation token, and a second that performs the action only when presented with it. It adds a round trip, and it is the difference between a bad answer and an unrecoverable action. We covered the same principle for direct tool calling in securing LLM tool calling in ASP.NET Core.

Defence-in-Depth Checklist

  • Transport requires authentication; anonymous access is explicit and limited to discovery

  • Token audience and issuer are validated against this server's own identifier

  • Protected resource metadata is published and the 401 challenge points at it

  • Every tool carries an [Authorize] policy mapped to an OAuth scope

  • Tools apply tenant and resource-level filters using the caller's identity, not ambient credentials

  • Tools call existing application services rather than the data layer directly, so authorization is not reimplemented

  • Destructive operations require explicit confirmation, not just authorization

  • The session identifier is never used as an authentication credential

  • For locally hosted servers, the Origin header is validated to prevent DNS rebinding from a browser

  • Tool invocations are audited with caller identity, arguments, and outcome

  • Rate limiting applies per caller, especially for tools that call paid downstream services

  • Tool inputs are validated as untrusted, because the model generated them from text you do not control

FAQ

Does an MCP server need OAuth, or is an API key enough?

An API key authenticates a client, not a user, so every request carries the same identity and per-user authorization becomes impossible. That may be acceptable for a single-purpose internal server with read-only tools over non-sensitive data. Anything acting on behalf of individual users needs OAuth, because you need the caller's identity inside the tool to filter what they can see.

How do I stop an MCP tool returning data the caller should not see?

Apply the caller's authorization inside the tool body, using the identity from the validated token, and filter by tenant and ownership exactly as your REST endpoints do. The most reliable way is to call the same application service your API calls rather than querying the data layer from the tool, so there is one authorization implementation instead of two that can drift.

What is token passthrough and why does the MCP spec forbid it?

Token passthrough is accepting a token that was issued for a different service. If your server only checks the signature and issuer, any valid token in your organisation unlocks your tools, which collapses the boundaries between services. Preventing it is simple: give the MCP server its own audience identifier and validate the audience claim on every request.

Can prompt injection cause an MCP tool to be called?

Yes, and this is the threat that authorization alone does not cover. The model decides which tools to call based on text it has read, which can include documents, tickets, or web content an attacker controls. Authorization limits what a compromised call can reach; explicit human confirmation on destructive tools is what stops an unintended call from doing lasting damage.

Should every MCP tool have its own authorization policy?

Yes. Server-wide authentication only distinguishes callers from strangers; it does not distinguish a read-only assistant from one permitted to move money. Per-tool policies mapped to OAuth scopes let you issue narrowly scoped tokens, so an agent that only needs lookups holds a credential that cannot invoke anything else.

How do I audit what an MCP client actually did?

Log every tool invocation with the authenticated caller, the tool name, the arguments, and the outcome, and treat that log as a security record with its own retention. Tool calls are the point where a model's decisions become real actions in your system, so without that trail you cannot answer what happened during an incident, and "the model decided to" is not an audit answer.


About the Author

I'm Celin Daniel, Co-founder of Coding Droplets. I've been building .NET and ASP.NET Core systems in production for 13+ years - APIs, distributed backends, enterprise platforms. Everything I write here comes from real shipping experience: patterns that held up, trade-offs that bit us, and lessons learned the hard way.

More from this blog

C

Coding Droplets

316 posts

Coding Droplets is your go-to resource for .NET and ASP.NET Core development. Whether you're just starting out or building production systems, you'll find practical guides, real-world patterns, and clear explanations that actually make sense.

From beginner-friendly tutorials to advanced architecture decisions. We publish fresh .NET content every day to help you grow at every stage of your career.