Table of Contents

Authentication and secure hosting

A server on STDIO or a named pipe inherits the identity of whoever launched it, so there is nothing to authenticate. The moment it is reachable over HTTP that stops being true: anyone who can reach the port can call every tool. This guide covers securing an MCP server over HTTP end to end — the OAuth 2.1 model the protocol expects, enforcing bearer tokens on the Streamable HTTP transport, binding a certificate on Windows, running as a service, and fronting the whole thing with IIS. It is cross-cutting by nature: the pieces live in the transport, the server, and the Windows platform, and none of them is sufficient alone.

The model

MCP authorization puts three parties in play, and keeps them separate:

  • The MCP client obtains an access token.
  • The authorization server issues it — a separate process, typically an existing identity provider.
  • The resource server is your MCP server. It accepts the token, validates it, and serves the request.

An MCP client obtains a token from the authorization server, then presents it to the resource server; the resource server validates the token with the authorization server before honouring the request.

The point worth internalising is that your server does not issue tokens and should not trust one on sight. In a correct deployment every request triggers a live validation against the authorization server — RFC 7662 introspection, or signature and claim checks against its published keys — before the request is honoured. That is what OnValidateAccessToken is for.

Enforcing bearer tokens

Four properties on the transport turn authentication on, and one event does the actual verifying:

procedure TForm1.StartProductionServer;
var
  Transport: TTMSMCPStreamableHTTPTransport;
begin
  Transport := TTMSMCPStreamableHTTPTransport.Create(Server);
  Transport.Port := 8443;
  Transport.MCPEndpoint := '/mcp';

  { TLS terminated here rather than at the proxy. On Windows, bind the
    certificate to the port with netsh first - see the Authentication guide. }
  Transport.UseSSL := True;
  Transport.CertFile := ConfigPath('server.crt');
  Transport.KeyFile := ConfigPath('server.key');

  { The address clients use, which is not the address we bind. }
  Transport.PublicHost := 'https://mcp.example.com';
  Transport.CORSMaxAgeSeconds := 86400;

  { Session lifetime for the per-client state the server keeps. }
  Transport.SessionTimeoutMs := 600000;
  Transport.CleanupIntervalMs := 60000;

  { OAuth 2.1: advertise discovery, demand a bearer token, and validate it. }
  Transport.EnableOAuthDiscovery := True;
  Transport.RequireBearerAuthentication := True;
  Transport.AuthorizationServers.Add('https://auth.example.com');
  Transport.ResourceScopesSupported.Add('inventory.read');
  Transport.ResourceScopesSupported.Add('inventory.write');
  Transport.RequiredScopes.Add('inventory.read');
  Transport.OnValidateAccessToken := TransportValidateAccessToken;

  Server.Transport := Transport;
  Server.Start;
end;

procedure TForm1.TransportValidateAccessToken(Sender: TObject;
  const AToken, AResourceURI: string; var AValidation: TTMSMCPAccessTokenValidation);
begin
  { Verify the token against the authorization server, then report the
    outcome so the transport can accept or reject the request. }
  AValidation := ValidateWithAuthServer(AToken, AResourceURI);
end;
Property Role
RequireBearerAuthentication Reject any request without a valid token.
AuthorizationServers The issuers a token may come from.
ResourceScopesSupported Every scope this server understands.
RequiredScopes The scopes a token must carry to be accepted.
OnValidateAccessToken Your verification, filling a TTMSMCPAccessTokenValidation.

The handler receives the token and the resource URI and reports back Valid, Subject, ClientId, Scopes, ExpiresAt, and on rejection ErrorCode and ErrorDescription. The transport enforces RequiredScopes against the Scopes you report — so returning the token's real scopes matters, not just Valid.

Validate the audience too. A token minted for a different resource is a valid token; accepting it is a confused-deputy bug. The resource identifier to check against is the canonicalised form published in your protected-resource metadata.

Discovery

EnableOAuthDiscovery := True publishes the documents a client needs to find its way to the authorization server without being configured by hand. An unauthenticated request then gets a 401 carrying a WWW-Authenticate header pointing at the protected-resource metadata, the client reads that, discovers the issuer, and obtains a token.

Three well-known routes are involved, all served by the transport on the same listener as the MCP endpoint:

Route Served when Carries
/.well-known/oauth-authorization-server EnableOAuthDiscovery The authorization-server metadata.
/.well-known/openid-configuration EnableOAuthDiscovery The same document, under the OpenID name some clients look for first.
/.well-known/oauth-protected-resource[<MCPEndpoint>] RequireBearerAuthentication The RFC 9728 protected-resource metadata the 401 points at.

All three are GET-only: another method gets 405, and with discovery off the first two are not recognised at all and fall through to 404 like any other unknown path.

OAuthMetadata and ProtectedResourceMetadata accept custom documents — assign a TJSONObject and it is serialized verbatim, which is how you describe a flow the defaults do not cover. The transport does not take ownership of either, so free them yourself. Left unassigned, a minimal authorization_code + PKCE document is generated on every request from the current server URL, so it always reflects PublicHost, UseSSL, and Port:

{
  "issuer": "https://mcp.example.com",
  "authorization_endpoint": "https://mcp.example.com/oauth/authorize",
  "token_endpoint": "https://mcp.example.com/oauth/token",
  "response_types_supported": ["code"],
  "grant_types_supported": ["authorization_code"],
  "code_challenge_methods_supported": ["S256"]
}

These properties only publish metadata about an authorization server. They do not validate anything — token issuance is the authorization server's job, and enforcement on incoming requests is OnValidateAccessToken's.

Path insertion

One detail of RFC 8414 and RFC 9728 surprises almost everyone, and it only bites when the issuer or resource identifier has a path component. The discovery document does not live nested under that path — it lives at the well-known root with the path appended as a suffix:

Issuer:            https://example.com/mcp/auth
Metadata document: https://example.com/.well-known/oauth-authorization-server/mcp/auth
NOT:               https://example.com/mcp/auth/.well-known/oauth-authorization-server

Behind a proxy this needs its own explicit rewrite rule — it does not fall out of a general "strip the prefix and forward" rule. Mounting at the domain root avoids the whole nuance, because an issuer with no path needs no insertion.

TLS on Windows

The Windows HTTP server talks directly to http.sys, the same kernel-mode component IIS is built on. That is not the Indy/OpenSSL pattern, and the difference is the single most common deployment surprise:

There is no code path on Windows that loads a raw .crt/.key PEM pair. CertFile is consulted only as a fallback pointer to a certificate already in the Windows certificate store. PEM files apply to the non-Windows implementation.

Get the certificate into the store, find its thumbprint — not its friendly name — and bind it to the port ahead of time:

Get-ChildItem Cert:\LocalMachine\My | Select-Object Thumbprint, FriendlyName, Subject
netsh http add sslcert ipport=0.0.0.0:9000 certhash=<THUMBPRINT> appid={00112233-4455-6677-8899-AABBCCDDEEFF}
netsh http add urlacl url=https://+:9000/ user=Everyone

Friendly names are not accepted anywhere in this path. IIS Manager lets you pick a certificate by friendly name, but that is an IIS convenience — http.sys itself only understands the thumbprint.

Letting the server do the binding

There is a second route, and it is what CertFile actually points at on Windows: an INI file naming a certificate that is already in the store. Point CertFile at a file shaped like this —

[certificate]
Hash=<THUMBPRINT>
StoreName=My
AppID={00112233-4455-6677-8899-AABBCCDDEEFF}

— and on a start where the URL is not yet reserved and no certificate is bound to the port, the server reads those three values and runs the netsh http add urlacl and netsh http add sslcert pair for you, elevating once through a UAC prompt. AppID may be omitted; the default GUID above is used.

So the property is not a PEM path and never was: it is a pointer to a store-resident certificate, either through this INI or not at all. The keys are read case-insensitively, and StoreName is the store the certificate lives in (My for the personal store).

Use this for a desktop or interactive deployment, where a one-time UAC prompt is acceptable. For a service, bind with netsh by hand instead — the reason is the next paragraph.

Pre-binding also avoids a real failure mode. Without it, the server tries to reserve the URL ACL itself on first start by elevating through ShellExecuteEx and waiting on the result. What it reserves is http(s)://+:<Port>/ for Everyone, so a reservation you make by hand has to name the same port the transport's Port property carries, or the server reserves a second one anyway. A Windows service has no interactive desktop to show a UAC prompt on, so depending on the service account that call either fails outright or hangs until the Service Control Manager's start timeout kills it — which looks like a crash loop that eventually succeeds. Binding ahead of time sidesteps it.

Running as a Windows service

Three things change when the same server runs as a service:

  • Configuration cannot be hardcoded. Ports, issuer URLs, and certificate thumbprints differ per machine and must come from a file next to the executable — an .ini is enough — not from constants.
  • There is no console. Anything written to standard output disappears. Log to a file or the event log, and note that this is the opposite of the STDIO transport's constraint, where standard output is reserved for protocol traffic.
  • Restarts wipe in-memory state. Sessions, and anything else held only in memory, are gone. Anything that must survive a restart has to be persisted.

Behind IIS as a reverse proxy

A common deployment fronts the service with IIS using ARR, terminating TLS at the public host.

First, a one-time prerequisite: enable ARR proxying. It is a server-wide setting, not a per-site one — IIS Manager → the server node → Application Request Routing CacheServer Proxy Settings → tick Enable proxy → Apply. Without it, a rewrite rule pointing at an absolute http://… URL does not actually proxy: you get a 404, 502, or IIS's generic 503 instead of a forwarded response, and it looks for all the world like a broken rewrite rule rather than one unticked checkbox.

Then four things to get right, each of which has cost people an afternoon:

Set PublicHost to the full public URL. The server generates absolute URLs in its discovery documents. Without PublicHost it builds them from the address it bound, leaking an internal host and — because TLS was terminated upstream and its own UseSSL is False — the wrong scheme. Use the full-URL form here, never the bare host[:port] form, which derives its scheme from that same UseSSL.

Add the rewrite rules near the top of the site's rule collection. A broad catch-all earlier in the list — a SPA fallback, a CMS permalink rule — silently swallows these paths. The rest of the site keeps working, so nothing looks wrong except that the rules never fire. For a root-mounted layout:

<rule name="Auth Server metadata" stopProcessing="true">
  <match url="^\.well-known/oauth-authorization-server$" />
  <action type="Rewrite" url="http://127.0.0.1:8935/.well-known/oauth-authorization-server" />
</rule>
<rule name="Auth Server endpoints" stopProcessing="true">
  <match url="^(authorize/approve|authorize|register|token|introspect)$" />
  <action type="Rewrite" url="http://127.0.0.1:8935/{R:1}" />
</rule>
<rule name="Resource Server metadata" stopProcessing="true">
  <match url="^\.well-known/oauth-protected-resource(/mcp)?$" />
  <action type="Rewrite" url="http://127.0.0.1:8934/.well-known/oauth-protected-resource{R:1}" />
</rule>
<rule name="Resource Server MCP endpoint" stopProcessing="true">
  <match url="^mcp$" />
  <action type="Rewrite" url="http://127.0.0.1:8934/mcp" />
</rule>

Turn off "reverse rewrite host in response headers". With it on, IIS rewrites Location: headers coming back from the backend, replacing the backend host with the site's public one. That is usually desirable for a normal proxy and actively breaks OAuth: the authorization step redirects to the client's own callback URL — http://localhost:6274/oauth/callback, or a native app's loopback listener — which has nothing to do with your site. Rewritten, the browser lands on https://yoursite.com/oauth/callback and gets a 404. The diagnostic that nails it: log the URL immediately before redirecting. If your code logs the right value and the browser ends up elsewhere, the corruption is downstream of you.

Prefer path-relative URLs in server-rendered pages. A root-relative URL (/authorize/approve) always resolves against the domain root and ignores the prefix the page was served under. The path-relative form (authorize/approve) resolves correctly under /mcp/auth/. This is a general web footgun, not an OAuth one, but a path-prefixed mount is where it shows up.

Troubleshooting

A port that looks open but answers with a canned 503

Worth its own entry because every instinct points the wrong way. If two http.sys-registered listeners on the same machine claim overlapping URL namespaces on one port — a leftover IIS site binding, another test process — the port looks perfectly healthy in netstat but requests come back as IIS/http.sys's own generic error page:

<HTML><HEAD><TITLE>Service Unavailable</TITLE></HEAD>
<BODY><h2>Service Unavailable</h2><hr><p>HTTP Error 503. The service is unavailable.</p></BODY></HTML>

That is not your application answering — this server returns JSON. http.sys is replying on your service's behalf before the request ever reaches your process.

The diagnostic: netstat -ano | findstr :PORT shows something listening, usually PID 4 ("System"), which is normal for any http.sys listener and not itself a red flag. The tell is that a direct call to http://127.0.0.1:PORT/… from the same machine, bypassing any proxy, still returns that page. If it does, the fix is almost always to move your service to a genuinely unclaimed port. You can hunt the conflicting registration with netsh http show urlacl and appcmd list site, but that is usually more effort than picking a different port.

Everything else

Symptom Likely cause
Every request rejected, tokens look fine RequireBearerAuthentication on with no OnValidateAccessToken, so nothing validates.
Client cannot discover the authorization server EnableOAuthDiscovery off, or the metadata rewrite rule missing / shadowed by an earlier rule.
Discovery document names an internal host or http:// PublicHost unset, or set in the bare host[:port] form behind a proxy.
Metadata 404s on a path-prefixed mount Path insertion — the document is at /.well-known/…/<issuer path>, not nested under the prefix.
OAuth redirect lands on your own site and 404s ARR host rewriting on outbound headers.
Service crash-loops at start, then succeeds URL ACL self-registration elevating with no interactive desktop. Pre-bind with netsh.
TLS never comes up on Windows Certificate not bound to the port by thumbprint, or a friendly name used instead.
Sessions vanish unexpectedly SessionTimeoutMs (five minutes by default), or a service restart clearing in-memory state.

Common mistakes

  • Trusting a token without validating it. A bearer token is a claim, not proof. Validate every request against the authorization server.
  • Ignoring the audience. A token valid for another resource is still a valid token. Check it was minted for yours.
  • Serving plain HTTP outside localhost. Bearer tokens travel in the clear.
  • Expecting PEM files to work on Windows. They do not. Bind by thumbprint.
  • Hardcoding ports and URLs in a service. They differ per machine; put them in a configuration file.
  • Writing diagnostics to standard output in a service. Nothing is listening.
  • Assuming in-memory state survives a restart. It does not.

See also