In the previous article, we saw how authentication with an X.509 certificate works — the client presents a certificate, the server validates it. This is one-way TLS: the server authenticates to the client (via the HTTPS certificate), but the client authenticates optionally.
mTLS (mutual TLS) makes both directions mandatory — both the client and the server present and validate certificates. No connection is accepted without cryptographically verified identity from both parties.
It is the standard mechanism in microservices architectures, service mesh (Istio, Linkerd), and any scenario where zero-trust literally means zero: no component implicitly trusts another.
1. TLS vs. mTLS — the visual difference
Standard TLS (one-way):
- The client connects to the server
- The server presents its certificate → the client validates it
- Encrypted connection established — the server does not know who the client is
mTLS (mutual TLS):
- The client connects to the server
- The server presents its certificate → the client validates it
- The server requests the client's certificate → the server validates it
- Encrypted connection established — both identities are cryptographically verified
The entire handshake happens at the TLS level, before the first byte of application data is sent. If validation fails, the TCP connection is closed — the application does not even get to process the request.
2. When to use mTLS
mTLS is not the solution for every scenario. The overhead of managing certificates is real. You choose it when:
- Internal microservices — ServiceA calls ServiceB without going through the internet; you want to guarantee that ServiceB accepts only calls from known components
- B2B APIs — an external partner calls your API; you want to completely eliminate passwords and API keys from the equation
- Zero-trust networking — no component is considered implicitly trusted, regardless of whether it is in the same datacenter
- Compliance — certain standards (PCI-DSS, HIPAA) require mutual authentication for sensitive data transfers
- Service mesh — Istio / Linkerd can enforce mTLS automatically between all services, transparent to the application
For public web applications with human users, mTLS is not suitable — browsers do not easily manage client certificates.
3. Possible architectures
There are two deployment scenarios with different implications for ASP.NET Core:
Scenario A — Kestrel exposed directly
ASP.NET Core (Kestrel) terminates TLS directly. The client certificate reaches the application natively, through HttpContext.Connection.ClientCertificate. The simplest to implement.
[Client with cert] ──mTLS──▶ [Kestrel / ASP.NET Core]
Scenario B — Reverse proxy in front
Nginx, Azure Application Gateway, or IIS terminates TLS. ASP.NET Core receives internal HTTP(S) connections without direct client certificate. The proxy must forward the certificate via header.
[Client with cert] ──mTLS──▶ [Nginx / App Gateway] ──HTTP──▶ [ASP.NET Core]
This is the most common scenario in production — and the source of most configuration problems.
4. Scenario A — mTLS directly with Kestrel
If Kestrel is exposed directly (internal containers, services without proxy), the configuration is straightforward — we saw the basics in the previous article, now we extend it for full mTLS.
4.1 Kestrel configuration with RequireCertificate
builder.WebHost.ConfigureKestrel(options =>
{
options.ListenAnyIP(5001, listenOptions =>
{
listenOptions.UseHttps(https =>
{
// Server certificate
https.ServerCertificate = new X509Certificate2(
"server.pfx", "server-password");
// Require client to present certificate
https.ClientCertificateMode = ClientCertificateMode.RequireCertificate;
// TLS-level validation (before middleware)
https.ClientCertificateValidation = (cert, chain, errors) =>
{
// Accept certificates signed by our internal CA
var trustedCa = new X509Certificate2("ca.crt");
chain!.ChainPolicy.TrustMode = X509ChainTrustMode.CustomRootTrust;
chain.ChainPolicy.CustomTrustStore.Add(trustedCa);
chain.ChainPolicy.RevocationMode = X509RevocationMode.NoCheck;
return chain.Build(cert);
};
});
});
});
4.2 Authentication middleware
builder.Services
.AddAuthentication(CertificateAuthenticationDefaults.AuthenticationScheme)
.AddCertificate(options =>
{
options.AllowedCertificateTypes = CertificateTypes.Chained;
options.RevocationMode = X509RevocationMode.NoCheck; // Online in production
options.Events = new CertificateAuthenticationEvents
{
OnCertificateValidated = context =>
{
var cert = context.ClientCertificate;
var cn = cert.GetNameInfo(X509NameType.SimpleName, false);
// Map CN to a known service
var knownServices = new HashSet<string>
{
"billing-service",
"notification-service",
"gateway-service"
};
if (!knownServices.Contains(cn))
{
context.Fail($"Unknown service identity: {cn}");
return Task.CompletedTask;
}
var claims = new[]
{
new Claim(ClaimTypes.NameIdentifier, cert.Thumbprint),
new Claim(ClaimTypes.Name, cn!),
new Claim("service", cn!),
new Claim("cert-expiry",
cert.NotAfter.ToString("yyyy-MM-dd"))
};
context.Principal = new ClaimsPrincipal(
new ClaimsIdentity(claims, context.Scheme.Name));
context.Success();
return Task.CompletedTask;
}
};
});
app.UseAuthentication();
app.UseAuthorization();
5. Scenario B — mTLS through reverse proxy
This is the most common scenario in production and the most frequently misconfigured. The proxy terminates TLS — ASP.NET Core does not see the client certificate unless the proxy explicitly forwards it via HTTP header.
5.1 Nginx — configuration
server {
listen 443 ssl;
server_name api.myservice.com;
# Server certificate
ssl_certificate /etc/nginx/certs/server.crt;
ssl_certificate_key /etc/nginx/certs/server.key;
# Trusted internal CA for client certificates
ssl_client_certificate /etc/nginx/certs/ca.crt;
# on = mandatory, optional = accepts also without certificate
ssl_verify_client on;
ssl_verify_depth 3;
location / {
proxy_pass http://aspnetcore-app:5000;
# Forward client certificate URL-encoded to ASP.NET Core
proxy_set_header X-ARR-ClientCert $ssl_client_escaped_cert;
# Standard proxy headers
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Host $host;
}
}
5.2 Azure Application Gateway — configuration
Azure Application Gateway supports mTLS natively. Steps in the portal:
- SSL Settings → Client Authentication → Add — upload the CA certificate (format
.cer, DER encoded) - Associate the SSL profile with the HTTPS listener
- Application Gateway automatically forwards the client certificate via the
X-ARR-ClientCertheader (URL-encoded, PEM format)
With Azure CLI:
# Create SSL profile with client authentication
az network application-gateway ssl-profile add \
--gateway-name myAppGateway \
--resource-group myRG \
--name mTLSProfile \
--client-auth-configuration requireClientCert
# Upload CA certificate
az network application-gateway client-cert add \
--gateway-name myAppGateway \
--resource-group myRG \
--name myCA \
--data ca.cer
5.3 ASP.NET Core — Certificate Forwarding
Now that the proxy forwards the certificate via header, ASP.NET Core must be configured to read it from there:
// Program.cs
builder.Services.AddCertificateForwarding(options =>
{
// Header set by Nginx / Application Gateway
options.CertificateHeader = "X-ARR-ClientCert";
// Nginx sends URL-encoded PEM — must decode
options.HeaderConverter = headerValue =>
{
if (string.IsNullOrEmpty(headerValue))
return null;
// URL decode
var decoded = Uri.UnescapeDataString(headerValue);
// Convert PEM → X509Certificate2
var bytes = Encoding.UTF8.GetBytes(decoded);
return new X509Certificate2(bytes);
};
});
// ORDER MATTERS — CertificateForwarding BEFORE Authentication
app.UseCertificateForwarding();
app.UseAuthentication();
app.UseAuthorization();
Security warning: If you enable
CertificateForwarding, anyone can inject theX-ARR-ClientCertheader with an arbitrary certificate. Make sure that only the trusted proxy can reach ASP.NET Core — via network policy, firewall, or accepting connections only from the proxy's IP.
6. .NET Client — how to send the certificate in mTLS
The mTLS client in .NET is identical to what we saw in the previous article — you configure HttpClientHandler with the client certificate. The difference is that in mTLS the server will also validate the certificate, not just the other way around.
// ServiceA calls ServiceB with mTLS
var clientCert = new X509Certificate2(
"service-a-client.pfx",
Environment.GetEnvironmentVariable("CERT_PASSWORD"),
X509KeyStorageFlags.EphemeralKeySet); // EphemeralKeySet on Linux/containers
var handler = new HttpClientHandler();
handler.ClientCertificates.Add(clientCert);
// Server certificate validation — in production validate the CA
handler.ServerCertificateCustomValidationCallback = (message, cert, chain, errors) =>
{
if (errors == SslPolicyErrors.None)
return true;
// Accept certificates signed by our internal CA
var trustedCa = new X509Certificate2("ca.crt");
chain!.ChainPolicy.TrustMode = X509ChainTrustMode.CustomRootTrust;
chain.ChainPolicy.CustomTrustStore.Add(trustedCa);
chain.ChainPolicy.RevocationMode = X509RevocationMode.NoCheck;
return chain.Build(new X509Certificate2(cert!));
};
var client = new HttpClient(handler)
{
BaseAddress = new Uri("https://service-b:5001")
};
6.1 Registration in DI with IHttpClientFactory
builder.Services.AddHttpClient("ServiceBClient", client =>
{
client.BaseAddress = new Uri(
builder.Configuration["Services:ServiceB:BaseUrl"]!);
})
.ConfigurePrimaryHttpMessageHandler(sp =>
{
var certPath = builder.Configuration["Certificates:ClientCertPath"]!;
var certPassword = builder.Configuration["Certificates:ClientCertPassword"]!;
var cert = new X509Certificate2(certPath, certPassword,
X509KeyStorageFlags.EphemeralKeySet);
var caCert = new X509Certificate2(
builder.Configuration["Certificates:CACertPath"]!);
var handler = new HttpClientHandler();
handler.ClientCertificates.Add(cert);
handler.ServerCertificateCustomValidationCallback = (_, serverCert, chain, errors) =>
{
if (errors == SslPolicyErrors.None) return true;
chain!.ChainPolicy.TrustMode = X509ChainTrustMode.CustomRootTrust;
chain.ChainPolicy.CustomTrustStore.Add(caCert);
chain.ChainPolicy.RevocationMode = X509RevocationMode.NoCheck;
return chain.Build(new X509Certificate2(serverCert!));
};
return handler;
});
7. mTLS in microservices architectures
When you have dozens of services, manual certificate management quickly becomes a problem. There are two approaches:
7.1 Manual management (internal PKI)
Suitable for a small number of services (under 10). You maintain an internal CA, issue certificates per service, automate renewal via scripts or Azure Key Vault. We detailed this approach in the previous article.
7.2 Service Mesh (Istio / Linkerd)
For large architectures, a service mesh manages mTLS transparent to the application. You write no certificate code in .NET services — the mesh injects a sidecar proxy (Envoy in Istio, Linkerd2-proxy in Linkerd) that handles the entire mTLS handshake automatically.
# Kubernetes: enable strict mTLS in Istio for a namespace
apiVersion: security.istio.io/v1beta1
kind: PeerAuthentication
metadata:
name: default
namespace: production
spec:
mtls:
mode: STRICT # reject any non-mTLS connection
With mode: STRICT, Istio guarantees that no inter-service communication in the production namespace happens without mTLS, regardless of what the application code says.
7.3 Certificates per service — naming convention
Regardless of approach, the recommended convention for CN in microservices architectures:
# Format: <service-name>.<namespace>.<environment>
CN=billing-service.internal.production
CN=notification-service.internal.production
CN=gateway-service.internal.production
Consistent naming allows automatic validation without manual whitelist — you accept any certificate with the pattern *.internal.production signed by your CA.
8. Testing mTLS with curl and .http files
8.1 curl
# Full mTLS test
curl --cert client.crt \
--key client.key \
--cacert ca.crt \
https://localhost:5001/api/data
# With PFX file
curl --cert-type P12 \
--cert client.pfx:dev-password \
--cacert ca.crt \
https://localhost:5001/api/data
# Verbose — see entire TLS handshake
curl -v --cert client.crt --key client.key --cacert ca.crt \
https://localhost:5001/api/data
8.2 Code test — WebApplicationFactory
public class MtlsIntegrationTests : IClassFixture<WebApplicationFactory<Program>>
{
private readonly WebApplicationFactory<Program> _factory;
public MtlsIntegrationTests(WebApplicationFactory<Program> factory)
{
_factory = factory;
}
[Test]
public async Task Request_WithValidClientCert_Returns200()
{
var cert = new X509Certificate2("test-client.pfx", "test-password");
var client = _factory.WithWebHostBuilder(b =>
{
b.ConfigureKestrel(k =>
k.ConfigureHttpsDefaults(h =>
{
h.ClientCertificateMode = ClientCertificateMode.RequireCertificate;
h.ClientCertificateValidation = (c, _, _) => true;
}));
}).CreateClient();
// Inject the certificate directly into HttpContext for tests
// (WebApplicationFactory does not support full TLS —
// we use CertificateForwarding with mock header)
var pemBytes = cert.Export(X509ContentType.Cert);
var pem = "-----BEGIN CERTIFICATE-----\n"
+ Convert.ToBase64String(pemBytes, Base64FormattingOptions.InsertLineBreaks)
+ "\n-----END CERTIFICATE-----";
client.DefaultRequestHeaders.Add(
"X-ARR-ClientCert", Uri.EscapeDataString(pem));
var response = await client.GetAsync("/api/data");
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK));
}
}
9. Common problems and their solutions
| Problem | Probable cause | Solution |
|---|---|---|
| Client certificate ignored behind proxy | AddCertificateForwarding not configured or middleware order is wrong |
UseCertificateForwarding() before UseAuthentication() |
X-ARR-ClientCert header empty |
Nginx not configured with ssl_client_certificate and ssl_verify_client on |
Check Nginx configuration; test with curl -v directly on Nginx |
CryptographicException on Linux |
Storage flags incompatible with Linux | Use X509KeyStorageFlags.EphemeralKeySet instead of MachineKeySet |
| Header injection by anyone | ASP.NET Core accepts X-ARR-ClientCert from any source |
Restrict ASP.NET Core access via firewall / network policy to proxy IP |
| Chain validation fails in container | CA is not in the container's trusted store | Use CustomRootTrust with explicit CA or add CA to /etc/ssl/certs at build |
| mTLS works locally, fails in Azure | Application Gateway forwards cert differently than local tests | Log the raw X-ARR-ClientCert header and check format (URL-encoded PEM vs Base64 DER) |
10. Production checklist
- ✅ ASP.NET Core accessible only from proxy (network policy / firewall) — prevents header injection
- ✅
UseCertificateForwarding()beforeUseAuthentication() - ✅
EphemeralKeySetfor certificates loaded in Linux containers - ✅ Logging for
OnAuthenticationFailedwith thumbprint and CN from rejected certificate - ✅ Alert on certificate expiration (server and client) — at least 30 days in advance
- ✅ Rotation strategy without downtime (whitelist with two thumbprints during transition)
- ✅
RevocationMode = Onlineenabled if CRL / OCSP is available - ✅ Explicitly tested: request without certificate →
400/403, not500 - ✅ Service certificates with consistent naming (
<service>.internal.<env>) - ✅ Internal CA stored in Azure Key Vault, accessed via Managed Identity
Conclusion
mTLS adds an authentication layer at the transport level — before the application processes anything. Combined with application-level authorization (claims, policies), you create a system where each component knows for sure who it is talking to.
The real complexity is not in configuring ASP.NET Core, which is relatively compact. It is in the infrastructure: how you manage the internal CA, how you automate issuing and renewing certificates, how you ensure the proxy forwards the client certificate correctly. Once these are solved, mTLS becomes transparent to the application code.
The security series continues with a different topic: signing XML documents and e-Invoice — where X.509 certificates again play a central role, but in a completely different context.