RO EN

mTLS in ASP.NET Core — mutual authentication through certificates

mTLS in ASP.NET Core — mutual authentication through certificates
Doru Bulubașa
04 May 2026
40 views

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):

  1. The client connects to the server
  2. The server presents its certificate → the client validates it
  3. Encrypted connection established — the server does not know who the client is

mTLS (mutual TLS):

  1. The client connects to the server
  2. The server presents its certificate → the client validates it
  3. The server requests the client's certificate → the server validates it
  4. 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:

  1. SSL Settings → Client Authentication → Add — upload the CA certificate (format .cer, DER encoded)
  2. Associate the SSL profile with the HTTPS listener
  3. Application Gateway automatically forwards the client certificate via the X-ARR-ClientCert header (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 the X-ARR-ClientCert header 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() before UseAuthentication()
  • EphemeralKeySet for certificates loaded in Linux containers
  • ✅ Logging for OnAuthenticationFailed with 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 = Online enabled if CRL / OCSP is available
  • ✅ Explicitly tested: request without certificate → 400/403, not 500
  • ✅ 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.