mTLS Setup Guide
Mutual TLS support in Duende IdentityServer enables two distinct security features:
- Client authentication — Clients present an X.509 certificate to authenticate to IdentityServer endpoints (instead of or in addition to client secrets)
- 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.
Local Development Setup
Section titled “Local Development Setup”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.
-
Install mkcert
Install via WinGet:
Terminal window winget install FiloSottile.mkcertOr via Chocolatey:
Terminal window choco install mkcertTerminal window brew install mkcertIf you use Firefox, also install NSS tools:
Terminal window brew install nssInstall NSS tools (required for browser and Firefox trust):
Terminal window sudo apt install libnss3-toolsThen download the latest
mkcertbinary for your architecture from the mkcert releases page, place it on yourPATH, and mark it executable:Terminal window sudo install mkcert-v*-linux-amd64 /usr/local/bin/mkcert -
Create a Local Certificate Authority
Install a local CA that will sign your development certificates:
Terminal window mkcert -installThis 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.
-
Generate Certificates
You will need a server certificate for the IdentityServer and API hostnames:
Terminal window mkcert localhost mtls.localhost api.localhostThis produces
localhost+2.pemandlocalhost+2-key.pemin 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 localhostThis produces
localhost-client.p12. Reference this file path when constructing theX509Certificate2for the .NET client. -
Configure the Hosts File
The mTLS sample uses
mtls.localhostandapi.localhostas hostnames. Add them to your hosts file so they resolve to127.0.0.1:Open
C:\Windows\System32\drivers\etc\hostsas Administrator and add:127.0.0.1 mtls.localhost127.0.0.1 api.localhostOpen
/etc/hostswithsudoand add:127.0.0.1 mtls.localhost127.0.0.1 api.localhost -
Configure Kestrel
Kestrel must be configured to accept client certificates before ASP.NET Core’s certificate authentication middleware can validate them. Use
AllowCertificatemode 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.localhostoptions.MutualTls.ClientCertificateAuthenticationScheme = "Certificate";}).AddMutualTlsSecretValidators();builder.Services.AddAuthentication().AddCertificate("Certificate", options =>{// Use CertificateTypes.All or CertificateTypes.Chained in productionoptions.AllowedCertificateTypes = CertificateTypes.SelfSigned;});
Production Deployment Patterns
Section titled “Production Deployment Patterns”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.
Certificate Forwarding in ASP.NET Core
Section titled “Certificate Forwarding in ASP.NET Core”When running behind a reverse proxy, configure Kestrel to skip TLS-level certificate negotiation and instead read the forwarded certificate from a request header:
// Tell Kestrel not to negotiate a client cert at the TLS layerbuilder.WebHost.ConfigureKestrel(serverOptions =>{ serverOptions.ConfigureHttpsDefaults(httpsOptions => { httpsOptions.ClientCertificateMode = ClientCertificateMode.NoCertificate; });});
// Read the client certificate from the forwarded header insteadbuilder.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; });Reverse Proxy Configuration
Section titled “Reverse Proxy Configuration”- Open IIS Manager → select the mTLS site → SSL Settings.
- Set Client Certificates to Require.
- Install the Application Request Routing (ARR) module to enable IIS as a reverse proxy.
- Configure ARR to forward requests to Kestrel. ARR automatically includes the client certificate in the
X-ARR-ClientCertheader when proxying to the backend. - Update the
CertificateHeaderin 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.
Configure ssl_verify_client and pass the URL-encoded client certificate to Kestrel via the X-SSL-CERT header:
server { listen 443 ssl; server_name mtls.yourdomain.com;
ssl_certificate /etc/ssl/certs/server.crt; ssl_certificate_key /etc/ssl/private/server.key; ssl_client_certificate /etc/ssl/certs/ca.crt; ssl_verify_client on;
location / { proxy_pass https://localhost:5001; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto https; # $ssl_client_escaped_cert is URL-encoded — matches the HeaderConverter above proxy_set_header X-SSL-CERT $ssl_client_escaped_cert; }}For the full SSL configuration reference, see the ngx_http_ssl_module documentation.
Enable mod_ssl, mod_proxy, mod_proxy_http, and mod_headers. Configure SSL client verification and forward the certificate in a request header:
<VirtualHost *:443> ServerName mtls.yourdomain.com
SSLEngine on SSLCertificateFile /etc/ssl/certs/server.crt SSLCertificateKeyFile /etc/ssl/private/server.key SSLCACertificateFile /etc/ssl/certs/ca.crt SSLVerifyClient require SSLVerifyDepth 2 SSLOptions +ExportCertData
# Forward the PEM-encoded client certificate RequestHeader set X-SSL-CERT "%{SSL_CLIENT_CERT}s"
ProxyPreserveHost On ProxyPass / https://localhost:5001/ ProxyPassReverse / https://localhost:5001/</VirtualHost>For the full SSL configuration reference, see the mod_ssl documentation.
Certificate Loading Strategies
Section titled “Certificate Loading Strategies”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:
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:
```csharpvar cert = LoadFromStore(configuration["Mtls:Thumbprint"]);
// Kestrel server certificatebuilder.WebHost.ConfigureKestrel(o => o.ConfigureHttpsDefaults(h => h.ServerCertificate = cert));
// Outbound mTLS clientvar handler = new SocketsHttpHandler();handler.SslOptions.ClientCertificates = new X509CertificateCollection { cert };var httpClient = new HttpClient(handler);Install the required packages:
dotnet add package Azure.Security.KeyVault.Secretsdotnet add package Azure.IdentityKey Vault stores the PKCS#12 bundle (certificate + private key) as a secret under the same name as the certificate object. Retrieve it via SecretClient:
using Azure.Identity;using Azure.Security.KeyVault.Secrets;using System.Security.Cryptography.X509Certificates;
async Task<X509Certificate2> LoadFromKeyVaultAsync(string vaultUri, string certName){ // DefaultAzureCredential automatically picks up: // - Managed identity when running in Azure (AKS, App Service, Container Apps) // - Developer credentials locally (Visual Studio, Azure CLI, etc.) var client = new SecretClient(new Uri(vaultUri), new DefaultAzureCredential());
KeyVaultSecret secret = (await client.GetSecretAsync(certName)).Value;
byte[] certBytes = Convert.FromBase64String(secret.Value); return new X509Certificate2( certBytes, (string?)null, X509KeyStorageFlags.EphemeralKeySet);}Install VaultSharp:
dotnet add package VaultSharpIssue a certificate from Vault’s PKI secrets engine:
using VaultSharp;using VaultSharp.V1.AuthMethods.Token;using System.Security.Cryptography.X509Certificates;
async Task<X509Certificate2> LoadFromVaultAsync( string vaultAddress, string token, string pkiMount, string roleName){ var settings = new VaultClientSettings( vaultAddress, new TokenAuthMethodInfo(token)); var client = new VaultClient(settings);
var result = await client.V1.Secrets.PKI.GetCredentialsAsync( pkiRoleName: roleName, pkiBackendMountPoint: pkiMount);
return X509Certificate2.CreateFromPem( result.Data.CertificateContent, result.Data.PrivateKeyContent);}mTLS Endpoint Strategies
Section titled “mTLS Endpoint Strategies”IdentityServer can expose mTLS endpoints using three different URL strategies. Configure the strategy via MutualTls.DomainName:
| Strategy | DomainName Value | Example Endpoint |
|---|---|---|
| Path-based | null or empty | https://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 domainoptions.MutualTls.DomainName = "identity-mtls.example.com";Discovery Document
Section titled “Discovery Document”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 aliasvar 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" }}ASP.NET Core OpenID Connect Clients
Section titled “ASP.NET Core OpenID Connect Clients”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:
- Present the client certificate on the handler’s back-channel HTTP calls.
- Point the handler’s endpoints at the
mtls_endpoint_aliasespublished in discovery.
Presenting the Client Certificate
Section titled “Presenting the Client Certificate”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:
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()));});Applying the mTLS Endpoint Aliases
Section titled “Applying the mTLS Endpoint Aliases”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.