An MCP server on stdio is about as exposed as a class library. The client launches it as a child process, talks to it over pipes, and the operating system’s process boundary is the whole security model. Nothing else can reach it.
Change the transport to HTTP so a hosted agent can use it, and you have quietly published an API whose entire purpose is to let a language model execute functions in your infrastructure. That is a different thing, and it deserves more thought than moving a connection string into an environment variable.
What the specification actually says
The MCP authorisation spec is opinionated in a way that is worth reading properly, because it saves you a design argument.
An MCP server that requires authorisation is an OAuth 2.1 resource server. It does not issue tokens and it does not authenticate users. It accepts access tokens, validates them, and serves or refuses. The MCP client is an OAuth 2.1 client, and the authorisation server is somebody else’s problem — Entra ID, Auth0, Keycloak, whatever you already run.
That division is the single most useful thing in the spec. A depressing number of first attempts at securing an MCP server invent a login endpoint on the server itself, which puts credential handling inside the process that runs tools for a model. Do not do that. Your server should never see a password.
The concrete requirements worth committing to memory:
- OAuth 2.1 with PKCE. PKCE is mandatory, not conditional on client type. The implicit flow and the password grant are gone.
- RFC 9728 protected resource metadata. Your server publishes a document describing which authorisation server protects it.
WWW-Authenticateon 401. An unauthenticated request must come back with a challenge that points at that metadata, so a client can bootstrap without being pre-configured.- RFC 7591 dynamic client registration is recommended, so that clients you have never met can register themselves.
The discovery chain is the part that makes remote MCP usable at all. A user pastes a URL into a client, the client gets a 401 with a pointer, follows it to the metadata, finds the authorisation server, registers, runs the flow, and comes back with a token. Nobody had to email anybody a client ID.
What the C# SDK gives you
The ModelContextProtocol C# SDK implements both ends of this, which means most of the work is configuration rather than protocol code.
On the server, McpAuthenticationHandler serves the protected resource metadata document and attaches the WWW-Authenticate challenge to unauthorised responses. On the client, ClientOAuthProvider handles the flow, token refresh and dynamic client registration.
Token validation itself is ordinary ASP.NET Core — the JWT bearer handler you already know:
builder.Services
.AddAuthentication(options =>
{
options.DefaultChallengeScheme = McpAuthenticationDefaults.AuthenticationScheme;
options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
})
.AddJwtBearer(options =>
{
options.Authority = builder.Configuration["Auth:Authority"];
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidateAudience = true,
// The audience MUST be this server's own resource identifier.
// See the token passthrough section below for why this line
// is the most important one on the page.
ValidAudience = builder.Configuration["Auth:Resource"],
ValidateLifetime = true,
};
})
.AddMcp(options =>
{
options.ResourceMetadata = new ProtectedResourceMetadata
{
Resource = new Uri(builder.Configuration["Auth:Resource"]!),
AuthorizationServers = { new Uri(builder.Configuration["Auth:Authority"]!) },
ScopesSupported = ["mcp:tools", "mcp:resources"],
};
});
app.UseAuthentication();
app.UseAuthorization();
app.MapMcp().RequireAuthorization();
RequireAuthorization() on the last line is doing more work than its length suggests. Leave it off and you have a fully configured authentication stack protecting nothing at all — the handlers run, the metadata is published, and every tool call still succeeds anonymously. It is an easy omission to make and a hard one to notice, because the server looks correct in every log.
For enterprise deployments where the user is already signed in to an identity provider, the SDK also supports the Identity Assertion Grant flow, which exchanges an existing IdP ID token for an MCP access token without an interactive browser redirect. That is what you want for an internal server where sending employees through a consent screen for an internal tool would be absurd.
The audience check is the whole ballgame
If you take one thing from this article, take this: validate that the token was issued for your server specifically.
The attack it prevents is called token passthrough, and it is the most likely way a well-intentioned MCP deployment gets compromised. It works like this. A user’s agent holds a token for some other service — say a Graph API token with broad mailbox scopes. A malicious or careless MCP client sends that token to your server. Your server, validating only the signature and issuer, sees a genuine token from a trusted authorisation server and accepts it. You have just granted access on the strength of a credential that was never meant for you, and your logs will show a perfectly valid request.
ValidateAudience = true with ValidAudience set to your own resource identifier is what stops it. It is one line, it is easy to disable while debugging, and it is the line most likely to still be disabled in production.
The same reasoning runs the other way. When your MCP server calls a downstream API on the user’s behalf, do not forward the token it received. Exchange it for a token scoped to that downstream service. A token that is valid for three services is three times the blast radius when it leaks.
Scopes should map to blast radius, not to tools
The instinct is one scope per tool. Resist it — it produces a consent screen with forty checkboxes that every user approves without reading, which is worse than three scopes they might actually think about.
Group by what a mistake would cost:
[McpServerTool, Description("Look up a customer by email address.")]
[Authorize(Policy = "mcp:read")]
public async Task<Customer?> FindCustomer(string email) => /* ... */;
[McpServerTool, Description("Issue a refund against an order.")]
[Authorize(Policy = "mcp:write")]
public async Task<RefundResult> IssueRefund(string orderId, decimal amount) => /* ... */;
Read-only tools, tools that change state, and tools that move money or touch personal data are three genuinely different consent decisions. Most agents only ever need the first.
And note what the second one implies. A refund tool is reachable by anything that can talk to a model that holds a write-scoped token, including a model that has just read an email containing instructions. Authorisation confirms the caller is allowed to issue refunds; it says nothing about whether this particular refund was the user’s idea. High-consequence tools want a human confirmation step in the calling application regardless of how good your OAuth configuration is. Defending .NET agents against prompt injection covers that side, and it is a genuinely separate problem.
Things that bite after it works
Tool descriptions are public. The tools/list response is typically available before any tool executes, and clients cache it. Everything you write in a [Description] is discoverable — do not document internal hostnames, table names or ID formats there. Write descriptions for the model, not for your colleagues.
Errors leak schema. A tool that surfaces a raw SqlException hands an attacker your column names via a language model that will happily relay them. Catch, log the detail server-side with a correlation ID, and return something bland.
Rate limits belong per token, not per IP. Every request from a hosted agent arrives from the same handful of cloud egress addresses, so IP-based limiting either throttles everybody or nobody. Key the limiter on the token subject.
Audit what ran, not just who connected. The useful log line is which tool was called with which arguments by which subject, correlated to a session. Authentication logs alone answer none of the questions you will actually have during an incident.
Token lifetime is a real trade. Long-lived tokens are convenient for long-running agents and expensive when they leak. Short lifetimes plus refresh is correct, and the SDK’s client provider handles refresh transparently, so the inconvenience is smaller than it feels.
Should this server exist at all?
Worth asking before any of the above. Not every capability needs to be an MCP server.
If exactly one application consumes these tools, function calling in that application is simpler, faster and has no network surface. MCP earns its cost when the same tools serve several clients, or when the client is somebody else’s agent. Function calling versus MCP in .NET works through where the line sits.
If the answer is yes and it needs to be remote, the sequence that keeps this manageable is: get it working over stdio first, where there is no auth to debug and you can iterate on the tools themselves; then move to HTTP with authorisation; then expose it. Debugging tool semantics and OAuth flows simultaneously is a bad afternoon. Building an MCP server in C# covers the first step.
Note: the MCP authorisation specification has changed materially between revisions — the resource-server model and the RFC 9728 discovery chain are recent, and earlier drafts said different things. The C# SDK’s auth surface has moved with it. Check the MCP authorization specification and the SDK release notes for the version you are on before copying configuration from anywhere, this page included. The principles — resource server, mandatory PKCE, strict audience validation, no token passthrough — have been stable while the API names have not.
Takeaway
Moving an MCP server to HTTP turns it into a public API that executes code on behalf of a language model. The spec’s answer is deliberately boring: be an OAuth 2.1 resource server, publish protected resource metadata, let a real authorisation server handle identity, and never see a password.
The C# SDK does most of it. The two lines that actually protect you are the ones easiest to leave out — RequireAuthorization() on the endpoint, and strict audience validation on the token. Get those right and the rest is configuration.
Have a correction or a topic you want covered? Email mani.bc72@gmail.com.
