Skip to content
New Livestream: How Banks Protect Their Apps with FAPI 2.0. Register Now!

mTLS Setup Guide

Mutual TLS support in Duende IdentityServer enables two distinct security features:

  1. Client authentication — Clients present an X.509 certificate to authenticate to IdentityServer endpoints (instead of or in addition to client secrets)
  2. Sender-constrained tokens — Access tokens are cryptographically bound to the client’s certificate, preventing token theft and replay

Both features are defined in RFC 8705: OAuth 2.0 Mutual-TLS Client Authentication and Certificate-Bound Access Tokens.

This guide covers the infrastructure side: generating certificates for local development, configuring reverse proxies in production, and loading certificates from secure stores. For IdentityServer client registration and options, see Client Authentication. For API-side token validation, see Proof-of-Possession.

The recommended tool for local mTLS development is mkcert, a cross-platform utility that creates locally trusted certificates backed by a private CA installed on your machine. Unlike the certificates bundled with samples (which use publicly known private keys), mkcert generates certificates whose private keys never leave your machine.

  1. Install mkcert

    Install via WinGet:

    Terminal window
    winget install FiloSottile.mkcert

    Or via Chocolatey:

    Terminal window
    choco install mkcert
  2. Create a Local Certificate Authority

    Install a local CA that will sign your development certificates:

    Terminal window
    mkcert -install

    This adds the CA to your OS trust store (and browser trust stores where supported). Certificates signed by this CA are treated as trusted on your machine.

  3. Generate Certificates

    You will need a server certificate for the IdentityServer and API hostnames:

    Terminal window
    mkcert localhost mtls.localhost api.localhost

    This produces localhost+2.pem and localhost+2-key.pem in the current directory. Reference these files in your Kestrel HTTPS configuration.

    Next, generate a client certificate in PKCS#12 format (used by the client application):

    Terminal window
    mkcert -client -pkcs12 localhost

    This produces localhost-client.p12. Reference this file path when constructing the X509Certificate2 for the .NET client.

  4. Configure the Hosts File

    The mTLS sample uses mtls.localhost and api.localhost as hostnames. Add them to your hosts file so they resolve to 127.0.0.1:

    Open C:\Windows\System32\drivers\etc\hosts as Administrator and add:

    127.0.0.1 mtls.localhost
    127.0.0.1 api.localhost
  5. Configure Kestrel

    Kestrel must be configured to accept client certificates before ASP.NET Core’s certificate authentication middleware can validate them. Use AllowCertificate mode so that client certificates are accepted but not required on every connection — IdentityServer’s mTLS middleware enforces the requirement on the mTLS endpoint specifically.

    Program.cs
    builder.WebHost.ConfigureKestrel(serverOptions =>
    {
    serverOptions.ConfigureHttpsDefaults(httpsOptions =>
    {
    httpsOptions.ClientCertificateMode = ClientCertificateMode.AllowCertificate;
    });
    });

    Then configure the IdentityServer mTLS options and ASP.NET Core certificate authentication:

    Program.cs
    builder.Services.AddIdentityServer(options =>
    {
    options.MutualTls.Enabled = true;
    options.MutualTls.DomainName = "mtls"; // serves mTLS endpoints at mtls.localhost
    options.MutualTls.ClientCertificateAuthenticationScheme = "Certificate";
    })
    .AddMutualTlsSecretValidators();
    builder.Services.AddAuthentication()
    .AddCertificate("Certificate", options =>
    {
    // Use CertificateTypes.All or CertificateTypes.Chained in production
    options.AllowedCertificateTypes = CertificateTypes.SelfSigned;
    });

In production, a reverse proxy (IIS, Nginx, or Apache) handles TLS termination and forwards the client certificate to Kestrel via a request header. This separates certificate negotiation from the application tier and supports load-balanced deployments.

When running behind a reverse proxy, configure Kestrel to skip TLS-level certificate negotiation and instead read the forwarded certificate from a request header:

Program.cs
// Tell Kestrel not to negotiate a client cert at the TLS layer
builder.WebHost.ConfigureKestrel(serverOptions =>
{
serverOptions.ConfigureHttpsDefaults(httpsOptions =>
{
httpsOptions.ClientCertificateMode = ClientCertificateMode.NoCertificate;
});
});
// Read the client certificate from the forwarded header instead
builder.Services.AddCertificateForwarding(options =>
{
// Must match the header name your reverse proxy sends (see configs below)
options.CertificateHeader = "X-SSL-CERT";
options.HeaderConverter = headerValue =>
{
if (string.IsNullOrWhiteSpace(headerValue))
return null;
// Reverse proxies typically URL-encode the PEM data
var certPem = Uri.UnescapeDataString(headerValue);
return X509Certificate2.CreateFromPem(certPem);
};
});

Register the middleware in the pipeline before UseAuthentication:

app.UseCertificateForwarding();
app.UseAuthentication();
app.UseAuthorization();

Also update AddCertificate to accept chained certificates from a real CA in production:

builder.Services.AddAuthentication()
.AddCertificate("Certificate", options =>
{
options.AllowedCertificateTypes = CertificateTypes.Chained;
});
  1. Open IIS Manager → select the mTLS site → SSL Settings.
  2. Set Client Certificates to Require.
  3. Install the Application Request Routing (ARR) module to enable IIS as a reverse proxy.
  4. Configure ARR to forward requests to Kestrel. ARR automatically includes the client certificate in the X-ARR-ClientCert header when proxying to the backend.
  5. Update the CertificateHeader in your ASP.NET Core certificate forwarding configuration to match:
builder.Services.AddCertificateForwarding(options =>
{
options.CertificateHeader = "X-ARR-ClientCert";
options.HeaderConverter = headerValue =>
{
if (string.IsNullOrWhiteSpace(headerValue))
return null;
// ARR sends the certificate as a base64-encoded DER blob
byte[] certBytes = Convert.FromBase64String(headerValue);
return new X509Certificate2(certBytes);
};
});

For full IIS hosting guidance, see Host ASP.NET Core on Windows with IIS and Configure certificate authentication on Microsoft Learn.

In production, certificates must be loaded from a secure store, not from disk paths in application configuration. The following examples show how to load a certificate programmatically for use with Kestrel or an outbound HttpClient.

Use X509Store to load a certificate by thumbprint from the local machine store:

X509Certificate2 LoadFromStore(string thumbprint)
{
using var store = new X509Store(StoreName.My, StoreLocation.LocalMachine);
store.Open(OpenFlags.ReadOnly);
var results = store.Certificates.Find(
X509FindType.FindByThumbprint,
thumbprint,
validOnly: true);
if (results.Count == 0)
throw new InvalidOperationException(
$"Certificate with thumbprint '{thumbprint}' not found in LocalMachine\\My.");
return results[0];
}

Import the certificate using certlm.msc (Local Machine Certificates MMC snap-in) or PowerShell:

Terminal window
Import-PfxCertificate `
-FilePath client.p12 `
-CertStoreLocation Cert:\LocalMachine\My `
-Password (Read-Host -Prompt "PFX password (leave blank if none)" -AsSecureString)
Then wire it into Kestrel or an outbound HTTP handler:
```csharp
var cert = LoadFromStore(configuration["Mtls:Thumbprint"]);
// Kestrel server certificate
builder.WebHost.ConfigureKestrel(o =>
o.ConfigureHttpsDefaults(h => h.ServerCertificate = cert));
// Outbound mTLS client
var handler = new SocketsHttpHandler();
handler.SslOptions.ClientCertificates = new X509CertificateCollection { cert };
var httpClient = new HttpClient(handler);

IdentityServer can expose mTLS endpoints using three different URL strategies. Configure the strategy via MutualTls.DomainName:

StrategyDomainName ValueExample Endpoint
Path-basednull or emptyhttps://identity.example.com/connect/mtls/token
Sub-domain"mtls"https://mtls.identity.example.com/connect/token
Separate domain"identity-mtls.example.com"https://identity-mtls.example.com/connect/token
// Path-based (default) — mTLS endpoints under /connect/mtls/*
options.MutualTls.DomainName = null;
// Sub-domain — mTLS endpoints on mtls.{yourdomain}
options.MutualTls.DomainName = "mtls";
// Separate domain — mTLS endpoints on a completely different domain
options.MutualTls.DomainName = "identity-mtls.example.com";

IdentityServer publishes the mTLS endpoint URLs in the discovery document under mtls_endpoint_aliases. Clients should use these aliases when mTLS is required:

var disco = await client.GetDiscoveryDocumentAsync("https://identity.example.com");
// Use the mTLS token endpoint alias
var tokenEndpoint = disco.MtlsEndpointAliases?.TokenEndpoint
?? disco.TokenEndpoint;

The discovery document will include an mtls_endpoint_aliases section like:

{
"issuer": "https://identity.example.com",
"token_endpoint": "https://identity.example.com/connect/token",
"mtls_endpoint_aliases": {
"token_endpoint": "https://mtls.identity.example.com/connect/token",
"revocation_endpoint": "https://mtls.identity.example.com/connect/revocation",
"introspection_endpoint": "https://mtls.identity.example.com/connect/introspect",
"device_authorization_endpoint": "https://mtls.identity.example.com/connect/deviceauthorization"
}
}

An interactive ASP.NET Core web application uses mTLS for the same two reasons as any other client: to authenticate the client with a certificate instead of a shared secret, and/or to obtain certificate-bound (sender-constrained) access tokens. What differs is how you wire it up.

The .NET client examples use Duende IdentityModel to request tokens directly, and they read the mTLS token endpoint from disco.MtlsEndpointAliases themselves. An interactive web application is different: the OpenID Connect handler makes its back-channel calls for you. It handles authorization code redemption and, on .NET 9 and later, the pushed authorization request. To use mTLS from such a client, you need to do two things:

  1. Present the client certificate on the handler’s back-channel HTTP calls.
  2. Point the handler’s endpoints at the mtls_endpoint_aliases published in discovery.

Configure the OpenID Connect handler’s BackchannelHttpHandler with a SocketsHttpHandler that carries the client certificate. This certificate is then used for authorization code redemption, refresh, and userinfo calls:

Program.cs
builder.Services.AddAuthentication(options =>
{
options.DefaultScheme = "cookies";
options.DefaultChallengeScheme = "oidc";
})
.AddCookie("cookies")
.AddOpenIdConnect("oidc", options =>
{
options.Authority = "https://identity.example.com";
options.ClientId = "confidential.client";
// No client secret: the client certificate authenticates the client
// Load the certificate using one of the approaches from
// "Certificate Loading Strategies" above (Key Vault, cert store, etc.)
var cert = LoadFromStore(builder.Configuration["Mtls:Thumbprint"]);
// Present the client certificate on every back-channel call
options.BackchannelHttpHandler = new SocketsHttpHandler
{
SslOptions = new SslClientAuthenticationOptions
{
ClientCertificates = new X509CertificateCollection { cert }
}
};
// Rewrite endpoints to their mTLS aliases (see below)
options.ConfigurationManager = new MtlsConfigurationManager(
$"{options.Authority}/.well-known/openid-configuration",
new OpenIdConnectConfigurationRetriever(),
new HttpDocumentRetriever(new HttpClient()));
});

The ASP.NET Core OpenID Connect handler reads the standard endpoints from discovery but does not understand the mtls_endpoint_aliases element. Without intervention it would call token_endpoint (the non-mTLS endpoint) instead of the mTLS alias, and client authentication would fail.

Wrap the standard configuration manager so that, after loading discovery, it overwrites the relevant endpoints with their mtls_endpoint_aliases values.

On .NET 9 and later, the OpenID Connect handler automatically uses Pushed Authorization Requests (PAR) when IdentityServer advertises support for them. The PAR request is a back-channel, client-authenticated call, so it must target the mTLS endpoint as well, which is why the configuration manager rewrites pushed_authorization_request_endpoint alongside the token endpoint.

using System.Text.Json;
using Microsoft.IdentityModel.Protocols;
using Microsoft.IdentityModel.Protocols.OpenIdConnect;
/// <summary>
/// Wraps the standard OpenID Connect configuration manager to apply
/// mtls_endpoint_aliases from the discovery document.
/// </summary>
internal sealed class MtlsConfigurationManager
: IConfigurationManager<OpenIdConnectConfiguration>
{
private readonly ConfigurationManager<OpenIdConnectConfiguration> _inner;
private readonly string _metadataAddress;
private readonly IDocumentRetriever _docRetriever;
private static readonly Dictionary<string, Action<OpenIdConnectConfiguration, string>> EndpointSetters = new()
{
["token_endpoint"] = (c, v) => c.TokenEndpoint = v,
["introspection_endpoint"] = (c, v) => c.IntrospectionEndpoint = v,
["device_authorization_endpoint"] = (c, v) => c.DeviceAuthorizationEndpoint = v,
["pushed_authorization_request_endpoint"] = (c, v) => c.PushedAuthorizationRequestEndpoint = v,
};
// OpenIdConnectConfiguration doesn't have a dedicated RevocationEndpoint property,
// so revocation_endpoint from mtls_endpoint_aliases is stored as additional data.
private static readonly string[] AdditionalEndpointKeys = ["revocation_endpoint"];
public MtlsConfigurationManager(
string metadataAddress,
OpenIdConnectConfigurationRetriever retriever,
IDocumentRetriever docRetriever)
{
_metadataAddress = metadataAddress;
_docRetriever = docRetriever;
_inner = new ConfigurationManager<OpenIdConnectConfiguration>(
metadataAddress, retriever, docRetriever);
}
public async Task<OpenIdConnectConfiguration> GetConfigurationAsync(CancellationToken cancel)
{
var config = await _inner.GetConfigurationAsync(cancel);
var json = await _docRetriever.GetDocumentAsync(_metadataAddress, cancel);
using var doc = JsonDocument.Parse(json);
if (!doc.RootElement.TryGetProperty("mtls_endpoint_aliases", out var aliases))
{
return config;
}
foreach (var (key, setter) in EndpointSetters)
{
if (aliases.TryGetProperty(key, out var value))
{
setter(config, value.GetString()!);
}
}
foreach (var key in AdditionalEndpointKeys)
{
if (aliases.TryGetProperty(key, out var value))
{
config.AdditionalData[key] = value.GetString()!;
}
}
return config;
}
public void RequestRefresh() => _inner.RequestRefresh();
}

With both pieces in place, the OpenID Connect handler authenticates to IdentityServer using the client certificate and targets the correct mTLS endpoints.