Port-Agnostic Localhost Redirect URIs for MCP Authentication with Duende IdentityServer
Summary: MCP servers and native apps bind to random ports at startup, which breaks IdentityServer's default exact-match redirect URI validation. Duende IdentityServer ships a built-in opt-in validator (AddAppAuthRedirectUriValidator) that accepts any port when the client registershttp://127.0.0.1as its redirect URI and has PKCE required. This aligns with RFC 8252's requirement that authorization servers allow any loopback port. If you needlocalhostor IPv6 loopback support beyond127.0.0.1, a customIRedirectUriValidatorcovers those cases.
If you're building OAuth 2.0 authentication for a Model Context Protocol (MCP) server, you've likely hit a wall: your server binds to a random port at startup, but IdentityServer's default redirect URI validation requires an exact match. http://127.0.0.1:12345/callback is not the same as http://127.0.0.1:54321/callback, and the authorization request fails.
This post explains why this happens, what the standards say, and how to configure Duende IdentityServer to handle it correctly.
Why MCP Servers Use Ephemeral Ports
MCP servers are local tools that run on a developer's machine and expose AI model capabilities to clients like Claude Desktop or VS Code extensions. When an MCP server needs to complete an OAuth flow, it opens a local HTTP listener to receive the authorization code callback. Because multiple MCP servers may run simultaneously, each one asks the OS for any available port rather than hardcoding one.
This is the same pattern used by native desktop apps, CLI tools, and developer utilities. The redirect URI looks like http://127.0.0.1:{random_port}/callback, where the port changes every time the server starts.
What RFC 8252 Requires for Loopback Redirect URIs
RFC 8252 (OAuth 2.0 for Native Apps) addresses this directly. Section 7.3 states:
"The authorization server MUST allow any port to be specified at the time of the request for loopback IP redirect URIs, to accommodate clients that obtain an available ephemeral port from the operating system at the time of the request."
The RFC also specifies that loopback redirect URIs should use the IP literal 127.0.0.1 (or [::1] for IPv6), not the hostname localhost. This is not just a style preference. Section 8.3 explains the security reason: localhost can be overridden in a system's name resolution configuration to point to a different address. An attacker with local access could redirect the callback to a process they control. Using 127.0.0.1 avoids that risk entirely.
OAuth 2.1 (currently in draft) reinforces this position and explicitly discourages http://localhost for native client redirect URIs for the same reason.
How Duende IdentityServer Validates Loopback Redirect URIs
By default, IdentityServer uses StrictRedirectUriValidator, which requires an exact string match between the registered redirect URI and the one in the authorization request. This is the right default for web applications, where redirect URIs are stable and predictable.
For native apps and MCP servers, IdentityServer ships a second validator: StrictRedirectUriValidatorAppAuth, registered via the AddAppAuthRedirectUriValidator extension method. This validator extends the strict behavior with one addition: if the client has RequirePkce = true and a registered redirect URI of exactly http://127.0.0.1, it accepts any http://127.0.0.1:{port}/{path} URI at runtime.
Here is the relevant logic:
C#
// From StrictRedirectUriValidatorAppAuth
// For redirect URIs:
result = baseResult || (context.Client.RequirePkce
&& context.Client.RedirectUris.Contains("http://127.0.0.1")
&& IsLoopback(context.RequestedUri));
// For post-logout redirect URIs:
result = baseResult || (client.RequirePkce
&& client.PostLogoutRedirectUris.Contains("http://127.0.0.1")
&& IsLoopback(requestedUri)); The IsLoopback method validates that the runtime URI is well-formed: it must use the http scheme, the 127.0.0.1 host, and a valid port number (0-65535). The path is allowed to vary.
Configuring Duende IdentityServer for Port-Agnostic Loopback Redirects
Register the AppAuth validator when setting up IdentityServer:
C#
builder.Services.AddIdentityServer()
// ... other configuration
.AddAppAuthRedirectUriValidator(); Configure your client with RequirePkce = true and register http://127.0.0.1 as the redirect URI (no port):
C#
new Client
{
ClientId = "mcp-server",
ClientName = "My MCP Server",
AllowedGrantTypes = GrantTypes.Code,
RequirePkce = true,
RequireClientSecret = false, // public client
RedirectUris = { "http://127.0.0.1" },
PostLogoutRedirectUris = { "http://127.0.0.1" },
AllowedScopes = { "openid", "profile", "api" }
}At runtime, your MCP server can use any port:
Shell
http://127.0.0.1:12345/callback // works
http://127.0.0.1:54321/callback // works
http://127.0.0.1:8080/callback // worksLimitations of AddAppAuthRedirectUriValidator
The built-in AppAuth validator only matches http://127.0.0.1. It does not support:
-
http://localhost:{port}(hostname, not IP literal) -
http://[::1]:{port}(IPv6 loopback)
If your MCP client sends a localhost or IPv6 redirect URI, the AppAuth validator will reject it. This is intentional and aligns with RFC 8252 and the OAuth 2.1 draft's security guidance.
If you need to support localhost or [::1] anyway (for example, because a third-party client library hardcodes it), you can implement a custom IRedirectUriValidator.
Custom IRedirectUriValidator for localhost and IPv6 Loopback Support
Implement IRedirectUriValidator and override the loopback check to accept any loopback address:
C#
public class LoopbackRedirectUriValidator : StrictRedirectUriValidator
{
private static readonly string[] LoopbackHosts =
["127.0.0.1", "localhost", "[::1]"];
public override async Task<bool> IsRedirectUriValidAsync(
RedirectUriValidationContext context)
{
// Check the standard strict match first
if (await base.IsRedirectUriValidAsync(context))
return true;
// Fall back to loopback port-agnostic match
return context.Client.RequirePkce
&& IsLoopbackUri(context.RequestedUri, context.Client.RedirectUris);
}
public override async Task<bool> IsPostLogoutRedirectUriValidAsync(
string requestedUri, Client client)
{
if (await base.IsPostLogoutRedirectUriValidAsync(requestedUri, client))
return true;
return client.RequirePkce
&& IsLoopbackUri(requestedUri, client.PostLogoutRedirectUris);
}
private static bool IsLoopbackUri(
string requestedUri,
IEnumerable<string> registeredUris)
{
if (!Uri.TryCreate(requestedUri, UriKind.Absolute, out var requested))
return false;
if (!string.Equals(requested.Scheme, "http", StringComparison.OrdinalIgnoreCase))
return false;
if (!LoopbackHosts.Contains(requested.Host, StringComparer.OrdinalIgnoreCase))
return false;
// Check that the registered URIs include a loopback base URI
return registeredUris.Any(registered =>
Uri.TryCreate(registered, UriKind.Absolute, out var reg)
&& LoopbackHosts.Contains(reg.Host, StringComparer.OrdinalIgnoreCase)
&& string.Equals(reg.Scheme, "http", StringComparison.OrdinalIgnoreCase));
}
}Register it with the generic extension method:
C#
builder.Services.AddIdentityServer()
.AddRedirectUriValidator<LoopbackRedirectUriValidator>(); Security note: If you enable localhost matching, document why and review whether your deployment environment allows DNS or hosts-file manipulation. For most developer-machine scenarios, the risk is acceptable. For production authorization servers issuing tokens to sensitive resources, prefer 127.0.0.1 only.
Decision Guide
flowchart TD
A[MCP / native app needs loopback redirect] --> B{Client sends 127.0.0.1?}
B -- Yes --> C[Use AddAppAuthRedirectUriValidator\nRegister 'http://127.0.0.1' as redirect URI]
B -- No --> D{Acceptable to require 127.0.0.1?}
D -- Yes --> E[Update client library to use 127.0.0.1\nThen use AddAppAuthRedirectUriValidator]
D -- No --> F[Implement custom IRedirectUriValidator\nwith LoopbackRedirectUriValidator pattern]Summary
Duende IdentityServer supports port-agnostic loopback redirect URIs for native apps and MCP servers. Call AddAppAuthRedirectUriValidator() during setup, register http://127.0.0.1 as the client's redirect URI, and set RequirePkce = true. The built-in validator handles the rest.
If you need localhost or IPv6 loopback support, the custom IRedirectUriValidator pattern above gives you full control without touching the rest of the validation pipeline.