Implement a secure MCP server using OAuth DPoP and Duende identity provider

This post walks through securing a Model Context Protocol (MCP) server with OAuth 2.0 DPoP (Demonstrating Proof-of-Possession) tokens, using Duende IdentityServer as the identity provider. The setup covers both sides of the connection: an MCP server that validates DPoP-bound access tokens before letting any tool run, and an ASP.NET Core client application that signs a user in through OpenID Connect and then calls the MCP server with a sender-constrained token.

The full working sample is on GitHub, and I recommend cloning it alongside this article since a few of the wiring details, like the ECDSA key files, only make sense once you see the whole project structure.

Why DPoP matters for MCP servers

An MCP server exposes tools that an AI agent invokes on a user’s behalf. If a plain bearer access token leaks, from a log file, a proxy, or a compromised browser extension, anyone holding it can call those tools with the same permissions as the original user. Bearer tokens carry no proof of possession, so a copy of the token is exactly as good as the original.

DPoP closes that gap by binding the access token to a private key that only the legitimate client holds. Every API call carries a DPoP proof, a short lived JWT signed with that private key, so a stolen access token is useless without the matching key sitting on the attacker’s machine.

This is the same problem mutual TLS (mTLS) solves, but DPoP does not need a TLS terminating proxy or a client certificate infrastructure in front of every service. That makes it a reasonable fit for MCP clients running inside IDE plugins or third party agent runtimes, where you cannot control the full network path between client and server.

Architecture

UI application authenticates via OpenID Connect, then calls the MCP server with a DPoP-bound access token.
UI application authenticates via OpenID Connect, then calls the MCP server with a DPoP-bound access token.

The UI application signs the user in against Duende IdentityServer using OpenID Connect. Once authenticated, it requests a DPoP-bound access token scoped to mcp:tools and uses that token, plus a fresh DPoP proof, to call the MCP server over HTTP. The MCP server validates the JWT and the proof together before it lets any tool execute.

Setting up the MCP server to require DPoP tokens

The MCP server needs three NuGet packages: ModelContextProtocol.AspNetCore for the MCP endpoint itself, Microsoft.AspNetCore.Authentication.JwtBearer for standard JWT validation, and Duende.AspNetCore.Authentication.JwtBearer, which layers DPoP specific checks on top of the bearer scheme. Duende’s package does the actual proof validation. Without it, the server accepts any bearer token, DPoP bound or not, and the whole exercise is pointless.

Here is the full startup wiring: JWT bearer validation, MCP resource metadata, DPoP configuration, the MCP server registration, and the authorization policy that gates access to the mcp:tools scope.

var httpMcpServerUrl = builder.Configuration["HttpMcpServerUrl"];
var identityProvider = builder.Configuration["IdentityProvider"];
 
builder.Services.AddAuthentication("Bearer")
    .AddJwtBearer(options =>
    {
        options.Authority = identityProvider;
        options.Audience = $"{identityProvider}/resources";
        
        options.TokenValidationParameters.ValidateAudience = true;
        options.TokenValidationParameters.ValidateIssuer = true;
 
        options.MapInboundClaims = false;
        options.TokenValidationParameters.ValidTypes = ["at+jwt"];
    })
    .AddMcp(options =>
    {
        options.ResourceMetadata = new()
        {
            Resource = new Uri($"{httpMcpServerUrl}/mcp"), 
            ResourceName = "MCP demo server",
            AuthorizationServers = [ new Uri(identityProvider!) ], 
            DpopBoundAccessTokensRequired = true,
            ResourceDocumentation = new Uri($"{httpMcpServerUrl}/health"),
            ScopesSupported = ["mcp:tools"], 
        };
    });
 
// layers DPoP onto the "token" scheme above
builder.Services.ConfigureDPoPTokensForScheme("Bearer", opt =>
{
    opt.ValidationMode = ExpirationValidationMode.IssuedAt; // IssuedAt is the default.
});
 
builder.Services.AddAuthorization();
 
builder.Services
    .AddMcpServer()
    .WithHttpTransport()
    .WithPrompts<PromptExamples>()
    .WithResources<DocumentationResource>()
    .WithTools<RandomNumberTools>()
    .WithTools<DateTools>();
 
builder.Services.AddHttpClient();
 
// change to scp or scope if not using magic namespaces from MS
// The scope must be validate as we want to force only delegated access tokens
// The scope is requires to only allow access tokens intended for this API
builder.Services.AddAuthorizationBuilder()
  .AddPolicy("mcp_tools", policy =>
        policy.RequireClaim("scope", "mcp:tools"));
 
// Add services to the container.

A few details here are easy to miss but matter in practice. options.MapInboundClaims = false stops ASP.NET Core from renaming standard JWT claims, like sub and scope, into long .NET claim type URIs, which otherwise breaks the scope check further down. DpopBoundAccessTokensRequired = true on the resource metadata is what tells MCP clients, through the protected resource metadata document, that plain bearer tokens will not work here. ConfigureDPoPTokensForScheme layers the proof validation onto the Bearer scheme itself, checking that the DPoP proof JWT matches the access token’s cnf claim and has not already been used.

The mcp_tools policy is what actually gates access, separate from authentication. RequireClaim(“scope”, “mcp:tools”) is a deliberate choice over checking scp, since Duende IdentityServer emits scopes under the scope claim by default. Get this claim name wrong when porting the code to a different identity provider and every call returns a 403 that looks like a broken login, which wastes debugging time.

Wiring the policy into the pipeline is a single line:

app.UseAuthentication();
app.UseAuthorization();
 
app.MapMcp("/mcp").RequireAuthorization("mcp_tools");

RequireAuthorization(“mcp_tools”) on the /mcp endpoint means the DPoP validation and the scope check both happen before any MCP tool call reaches the PromptExamples, DocumentationResource, or RandomNumberTools implementations registered earlier. If either check fails, the request never reaches the tool code, so there is no need to duplicate authorization logic inside individual tools.

Building the MCP client

The client side lives inside a regular ASP.NET Core web application, because it needs a real user to authenticate through the browser before it can call the MCP server on that user’s behalf. Two identities are in play here, one for the signed in user and one for the confidential client application itself, and both eventually feed into the token that calls the MCP server.

The required packages are Duende.AccessTokenManagement.OpenIdConnect for automatic token refresh, Microsoft.AspNetCore.Authentication.OpenIdConnect for the sign in flow, Microsoft.SemanticKernel to wire MCP tools into a Semantic Kernel agent, and the ModelContextProtocol packages for the client and server contracts.

Authentication setup looks like a fairly standard OpenID Connect client, until the DPoP key material shows up near the end:

        builder.Services.AddAuthentication(options =>
        {
            options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme;
            options.DefaultChallengeScheme = OpenIdConnectDefaults.AuthenticationScheme;
        })
       .AddCookie(CookieAuthenticationDefaults.AuthenticationScheme, options =>
       {
           options.ExpireTimeSpan = TimeSpan.FromHours(8);
           options.SlidingExpiration = false;
           options.Events.OnSigningOut = async e =>
           {
               await e.HttpContext.RevokeRefreshTokenAsync();
           };
       })
       .AddOpenIdConnect(OpenIdConnectDefaults.AuthenticationScheme, options =>
       {
           options.Authority = "https://localhost:5101";
           options.ClientId = "McpWebClient";
           options.ClientSecret = "ddedF4f289k$3eDa23ed0iTk4Raq&tttk23d08nhzd";
           options.ResponseType = "code";
           options.ResponseMode = "query";
           options.UsePkce = true;
 
           options.Scope.Clear();
           options.Scope.Add("openid");
           options.Scope.Add("profile");
           options.Scope.Add("mcp:tools");
           options.Scope.Add("offline_access");
           options.GetClaimsFromUserInfoEndpoint = true;
           options.SaveTokens = true;
 
           options.TokenValidationParameters = new TokenValidationParameters
           {
               NameClaimType = "name",
               RoleClaimType = "role"
           };
       });
 
        var privatePem = File.ReadAllText(Path.Combine(builder.Environment.ContentRootPath, "ecdsa384-private.pem"));
        var publicPem = File.ReadAllText(Path.Combine(builder.Environment.ContentRootPath, "ecdsa384-public.pem"));
        var ecdsaCertificate = X509Certificate2.CreateFromPem(publicPem, privatePem);
        var ecdsaCertificateKey = new ECDsaSecurityKey(ecdsaCertificate.GetECDsaPrivateKey());
 
        // add automatic token management
        builder.Services.AddOpenIdConnectAccessTokenManagement(options =>
        {
            var jwk = JsonWebKeyConverter.ConvertFromSecurityKey(ecdsaCertificateKey);
            jwk.Alg = "ES384";
            options.DPoPJsonWebKey = DPoPProofKey.ParseOrDefault(JsonSerializer.Serialize(jwk));
        });
 
        builder.Services.AddHttpClient();
        builder.Services.AddUserAccessTokenHttpClient("dpop-api-client", configureClient: client =>
        {
            client.BaseAddress = new Uri("https://localhost:5103");
        });
 
        builder.Services.AddAuthorization(options =>
        {
            options.FallbackPolicy = options.DefaultPolicy;
        });

Three details matter beyond the usual OpenID Connect boilerplate. First, calling RevokeRefreshTokenAsync on sign out is easy to skip, but leaving refresh tokens live after logout means a stolen cookie session can outlive the browser session it came from. Second, the ECDSA key pair, ecdsa384-private.pem and ecdsa384-public.pem, is what DPoP proofs get signed with, and AddOpenIdConnectAccessTokenManagement wires that key into every token request, so Duende’s client library builds the proof JWT automatically on each call. In this sample the key files sit under the content root, which is fine for a local demo, but a production deployment should pull that private key from a key vault or a managed HSM instead of reading it off disk.

Third, AddUserAccessTokenHttpClient registers a named HTTP client, dpop-api-client, that automatically attaches both the DPoP bound access token and a fresh DPoP proof header to every outgoing request against that base address. You never touch the Authorization header directly in application code, which also means it is harder to accidentally leak the raw token into logs.

That named client feeds a small transport factory used by the MCP client library:

 private IClientTransport CreateMcpTransport(IHttpClientFactory clientFactory)
    {
        var httpClient = clientFactory.CreateClient("dpop-api-client");
        var httpMcpServerUrl = _configuration["HttpMcpServerUrl"] ?? throw new ArgumentNullException("Configuration missing for HttpMcpServerUrl");
        return new SseClientTransport(new() { Endpoint = new Uri(httpMcpServerUrl), Name = "Secure Client" }, httpClient);
    }

CreateMcpTransport resolves the named HttpClient and wraps it in an SseClientTransport, so all the DPoP work from the previous step happens transparently underneath, the transport code itself has no idea DPoP is involved. If HttpMcpServerUrl is missing from configuration, the method throws immediately, which is the right failure mode here. Falling back silently to a default URL would make a misconfigured deployment look like it started up fine, when it actually cannot reach the MCP server at all.

That transport gets built and consumed once, on first use:

  public async Task EnsureSetupAsync(IHttpClientFactory clientFactory)
    {
        if (_initialized) return;
 
        _mcpClient = await McpClientFactory.CreateAsync(CreateMcpTransport(clientFactory), GetMcpOptions());
        await _kernel.ImportMcpClientToolsAsync(_mcpClient);
 
        _promptingService = new PromptingService(_kernel, autoInvoke: _mode == ApprovalMode.Elicitation);
        _initialized = true;
    }

EnsureSetupAsync builds the MCP client, imports its tools into the Semantic Kernel through ImportMcpClientToolsAsync, and sets up a PromptingService for either automatic or elicited tool approval depending on the configured ApprovalMode. The _initialized guard means this method only does real work once per client instance, which matters because McpClientFactory.CreateAsync performs a round trip to the MCP server’s initialize endpoint, and you do not want that happening on every request.

Registering the client with Duende IdentityServer

None of the client side DPoP plumbing works unless Duende IdentityServer is configured to actually issue DPoP bound tokens for this client. The API scope and client registration look like this:

public static IEnumerable<ApiScope> ApiScopes =>
[
	new ApiScope("mcp:tools")
];
 
public static IEnumerable<Client> Clients =>
[
	new Client
	{
		ClientId = "McpWebClient",
		// In a real app, use a key vault
		ClientSecrets = { new Secret("ddedF4f289k$3eDa23ed0iTk4Raq&tttk23d08nhzd".Sha256()) },
 
		AllowedGrantTypes = GrantTypes.CodeAndClientCredentials,
 
		RedirectUris = { "https://localhost:5102/signin-oidc" },
		FrontChannelLogoutUri = "https://localhost:5102/signout-oidc",
		PostLogoutRedirectUris = { "https://localhost:5102/signout-callback-oidc" },
 
		RequireDPoP = true,
		RequirePushedAuthorization = true,
 
		AllowOfflineAccess = true,
		AllowedScopes = { "openid", "profile", "offline_access", "mcp:tools" }
	}
];

RequireDPoP = true on the client registration is what forces Duende to reject any token request that does not present a matching DPoP proof at the token endpoint, closing off the possibility of the client quietly falling back to plain bearer tokens if the DPoP key is ever missing on the client side. RequirePushedAuthorization = true adds pushed authorization requests (PAR) on top, so the authorization request itself never travels through the browser as a URL query string, only an opaque request URI reference does. Both settings are opt-in per client in Duende, so it is worth checking this configuration explicitly rather than assuming DPoP is enforced server side just because the client sends proofs. A misconfigured client registration will silently accept bearer-only tokens.

Practical notes and trade-offs

DPoP adds real protection against token theft, but it is not free. Every access token request and every API call needs a fresh proof JWT, and clock skew between client and server becomes a genuine debugging headache if the proof validation window is too tight. The ValidationMode = ExpirationValidationMode.IssuedAt setting shown earlier is Duende’s default and generally the more forgiving option, so it is a reasonable starting point unless you have a specific replay window requirement to satisfy.

If you already run mTLS between services, for instance inside a service mesh, certificate bound tokens might be a lighter alternative to DPoP for server to server calls, since they do not require every client to manage its own signing key or attach a proof header to each request. DPoP earns its complexity in the scenario this article targets: browser based or IDE hosted MCP clients calling a server over the open internet, where you cannot rely on network level mutual authentication and the token has to defend itself.

One limitation worth keeping in mind is that DPoP protects the token against replay by a different party, but it does not stop a compromised client machine from making unauthorized calls with its own valid key. If the machine running the MCP client is compromised, the attacker has access to the same private key and can mint proofs exactly like the legitimate client would. DPoP is a mitigation for token theft in transit, not a substitute for endpoint security on the machine holding the key.

For teams evaluating this pattern, a fair comparison worth running before committing to DPoP is a straightforward cost check against mTLS sender constraining, particularly if the MCP server sits behind Azure API Management or a similar gateway that already terminates TLS. The added latency of DPoP proof generation and validation on every call is usually small, but it is worth measuring under your actual token issuance volume rather than assuming it is negligible.

Leave a Reply

Discover more from Behind the Stack

Subscribe now to keep reading and get access to the full archive.

Continue reading