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.
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 scopeTools 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
Originheader is validated to prevent DNS rebinding from a browserTool 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.
GitHub: codingdroplets
YouTube: Coding Droplets
Website: codingdroplets.com






