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" }}