.NETSecurity
June 22, 2026

Replacing Shared API Keys with mTLS in ASP.NET Core Without Baking Certificates into Containers

A route-planning worker calls an internal ASP.NET Core dispatch API with a shared API key. The key is copied into configuration for every worker instance, has no useful client identity, and may remain valid long after a deployment changes.

Moving the API behind HTTPS protects the key while it travels across the network, but it does not solve every problem. Any process that obtains the shared value can present it as the client. The server knows that a secret was supplied, but it cannot prove which workload owns that secret.

Mutual Transport Layer Security, or mTLS, changes the trust model. The server proves its identity with a server certificate, and the client proves its identity with a client certificate. ASP.NET Core can then convert the validated client identity into claims and apply authorization policies before the request reaches the business operation.

The deployment must also protect private keys. A certificate copied into a container image becomes part of every image copy, registry transfer, and cached build layer. The safer design is to mount the certificate at runtime from a secret store and load it only when the process starts.

Context and Scope

Assume an internal delivery platform contains two services:

  • route-worker calculates routes and submits dispatch plans.
  • dispatch-api accepts plans and assigns them to delivery teams.

The old design sends one shared key with every request:

route-worker
    |
    | HTTPS plus shared API key
    v
dispatch-api

The target design uses mTLS and claim-based authorization:

Internal certificate authority
      |
      +--> Server certificate for dispatch-api
      |
      +--> Client certificate for route-worker

route-worker
    |
    | TLS handshake
    | - validates dispatch-api certificate
    | - presents route-worker certificate
    v
Kestrel
    |
    | requires a client certificate
    v
ASP.NET Core certificate authentication
    |
    | validates the expected client identity
    | creates claims
    v
Authorization policy
    |
    v
Dispatch operation

The expected security properties are:

  • Traffic is encrypted.
  • The client verifies that it reached the intended server.
  • The server requires a client certificate before accepting the connection.
  • ASP.NET Core verifies that the certificate belongs to an approved workload.
  • Authorization rules depend on claims derived from the validated identity.
  • Private keys are not stored in source control or container images.

What Certificates Add Beyond a Shared Key

A digital certificate connects an identity to a public key. The matching private key remains with the certificate owner.

Public-key cryptography uses two related keys:

  • The public key can be distributed.
  • The private key must remain secret.
  • Data signed with the private key can be checked with the public key.
  • Data encrypted for the public key can be recovered only with the matching private key.

A certificate authority, or CA, signs certificates so other systems can verify that they belong to an accepted trust chain. During a normal TLS connection, the server presents its certificate and proves possession of the corresponding private key.

With mTLS, the client also presents a certificate and proves possession of its private key.

Approach What the server learns
Shared API key The caller knows a shared string
TLS only The channel is encrypted and the server is authenticated
mTLS The channel is encrypted and both endpoints present certificate identities

mTLS does not remove the need for application authorization. A certificate may be technically valid because it chains to a trusted CA, but the application must still decide whether that specific certificate may submit dispatch plans.

Step 1: Create Separate Server and Client Certificates

The server certificate and client certificate have different owners and purposes.

The server certificate identifies dispatch-api. Its subject must match the server name used by the client.

The client certificate identifies route-worker. It should be issued for that workload rather than reused by unrelated services.

A certificate with its private key can be packaged in a password-protected PKCS#12 file with the .pfx extension.

openssl pkcs12 -export   -out RouteWorkerClient.pfx   -inkey RouteWorkerClient.key   -in RouteWorkerClient.crt   -password pass:replace-this-password

The .crt file contains the public certificate. The .key file contains the private key. The resulting .pfx contains both parts and must be protected as a secret.

Do not use one certificate for every internal service. Separate identities make authorization and replacement more precise.

Step 2: Keep Certificates Out of the Container Image

Do not copy the .pfx file in the Dockerfile and do not commit it beside the application code.

Use a runtime secret:

Container image
  - application binaries
  - runtime dependencies
  - no private certificate

Runtime environment
  Kubernetes Secret or another secret store
          |
          v
  Read-only mounted certificate file
          |
          v
  X509CertificateLoader
          |
          +--> Kestrel server certificate
          |
          +--> HttpClient client certificate

For a host-based deployment, an operating system certificate store can protect owned certificates. For containerized microservices, a file mounted from a Kubernetes Secret is a practical way to provide the certificate without embedding it in the image.

The certificate password is also sensitive. Supply it through protected runtime configuration rather than a literal value in code.

Step 3: Configure Kestrel with the Server Certificate

Kestrel must expose an HTTPS endpoint with the server certificate and require a client certificate.

using System.Net;
using System.Security.Cryptography.X509Certificates;
using Microsoft.AspNetCore.Server.Kestrel.Https;

var builder = WebApplication.CreateBuilder(args);

var serverCertificatePath =
    builder.Configuration["Certificates:Server:Path"]
    ?? throw new InvalidOperationException(
        "The server certificate path is missing.");

var serverCertificatePassword =
    builder.Configuration["Certificates:Server:Password"]
    ?? throw new InvalidOperationException(
        "The server certificate password is missing.");

var serverCertificate =
    X509CertificateLoader.LoadPkcs12FromFile(
        serverCertificatePath,
        serverCertificatePassword);

builder.WebHost.ConfigureKestrel(
    (_, serverOptions) =>
    {
        serverOptions.Listen(
            IPAddress.Any,
            8443,
            listenOptions =>
            {
                listenOptions.UseHttps(
                    httpsOptions =>
                    {
                        httpsOptions.ServerCertificate =
                            serverCertificate;

                        httpsOptions.ClientCertificateMode =
                            ClientCertificateMode
                                .RequireCertificate;
                    });
            });
    });

RequireCertificate makes the client certificate mandatory during the secure connection.

If the application supports both certificate-authenticated and other clients on the same endpoint, an optional certificate mode can be used instead. For an internal endpoint dedicated to trusted workloads, requiring the certificate creates a clearer boundary.

The server certificate must also be valid for the server name. A client should not be configured to ignore an invalid hostname or untrusted certificate.

Step 4: Validate the Client Certificate in ASP.NET Core

The TLS layer verifies that the client presented a certificate and checks its certificate chain. The application still needs to verify that the certificate represents an approved caller.

Add the certificate authentication package and configure certificate authentication.

using System.Security.Claims;
using Microsoft.AspNetCore.Authentication.Certificate;

builder.Services
    .AddAuthentication(
        CertificateAuthenticationDefaults
            .AuthenticationScheme)
    .AddCertificate(options =>
    {
        options.Events =
            new CertificateAuthenticationEvents
            {
                OnCertificateValidated = context =>
                {
                    var certificate =
                        context.ClientCertificate;

                    if (!IsApprovedRouteWorker(
                        certificate,
                        builder.Configuration))
                    {
                        context.Fail(
                            "The client certificate is not approved.");

                        return Task.CompletedTask;
                    }

                    var clientName =
                        certificate.GetNameInfo(
                            X509NameType.SimpleName,
                            false);

                    var claims = new[]
                    {
                        new Claim(
                            ClaimTypes.NameIdentifier,
                            certificate.Thumbprint),

                        new Claim(
                            ClaimTypes.Name,
                            clientName),

                        new Claim(
                            "client_type",
                            "route-worker")
                    };

                    context.Principal =
                        new ClaimsPrincipal(
                            new ClaimsIdentity(
                                claims,
                                context.Scheme.Name));

                    context.Success();

                    return Task.CompletedTask;
                }
            };
    });

The validation function should reject certificates that do not belong to the expected trust boundary.

private static bool IsApprovedRouteWorker(
    X509Certificate2 certificate,
    IConfiguration configuration)
{
    var expectedIssuer =
        configuration[
            "Certificates:Clients:ExpectedIssuer"];

    var expectedName =
        configuration[
            "Certificates:Clients:ExpectedName"];

    if (string.IsNullOrWhiteSpace(expectedIssuer) ||
        string.IsNullOrWhiteSpace(expectedName))
    {
        return false;
    }

    var certificateName =
        certificate.GetNameInfo(
            X509NameType.SimpleName,
            false);

    return certificate.Verify()
        && string.Equals(
            certificate.Issuer,
            expectedIssuer,
            StringComparison.Ordinal)
        && string.Equals(
            certificateName,
            expectedName,
            StringComparison.Ordinal);
}

Verify() checks the certificate chain and validity according to the machine's trust configuration. The explicit issuer and name checks restrict the application to the intended client identity.

Do not accept every certificate signed by any generally trusted CA. A valid public certificate is not automatically an approved internal workload.

Step 5: Convert Identity into an Authorization Policy

Authentication answers, "Who is calling?"

Authorization answers, "May this caller execute this operation?"

Create a policy that requires the claim produced only for the approved route worker.

builder.Services
    .AddAuthorizationBuilder()
    .AddPolicy(
        "RoutePlanWriters",
        policy =>
        {
            policy.RequireAuthenticatedUser();

            policy.RequireClaim(
                "client_type",
                "route-worker");
        });

Add the authentication and authorization middleware before mapping protected endpoints.

var app = builder.Build();

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

app.MapControllers();

app.Run();

Protect the API operation with the policy.

using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;

[ApiController]
[Route("api/dispatch-plans")]
public sealed class DispatchPlansController :
    ControllerBase
{
    [Authorize(Policy = "RoutePlanWriters")]
    [HttpPost]
    public IActionResult Submit(
        DispatchPlanRequest request)
    {
        return Accepted();
    }
}

The controller no longer checks a shared string. It receives a ClaimsPrincipal created only after the certificate passes the configured validation.

More detailed policies can use certificate-derived claims to distinguish service groups or operations. Keep the claims limited to facts the application has actually validated.

Step 6: Attach the Client Certificate to HttpClient

The calling worker loads its client certificate and adds it to the primary HttpClientHandler.

using System.Security.Cryptography.X509Certificates;

var clientCertificatePath =
    builder.Configuration["Certificates:Client:Path"]
    ?? throw new InvalidOperationException(
        "The client certificate path is missing.");

var clientCertificatePassword =
    builder.Configuration["Certificates:Client:Password"]
    ?? throw new InvalidOperationException(
        "The client certificate password is missing.");

var clientCertificate =
    X509CertificateLoader.LoadPkcs12FromFile(
        clientCertificatePath,
        clientCertificatePassword);

builder.Services
    .AddHttpClient(
        "DispatchApi",
        client =>
        {
            client.BaseAddress =
                new Uri(
                    "https://dispatch-api:8443/");
        })
    .ConfigurePrimaryHttpMessageHandler(
        () =>
        {
            var handler =
                new HttpClientHandler();

            handler.ClientCertificates.Add(
                clientCertificate);

            return handler;
        });

The default server-certificate validation should remain enabled. Do not install a callback that accepts every server certificate to make development errors disappear. Doing so removes the server identity check and makes man-in-the-middle attacks possible.

The client certificate must include its private key. The worker proves possession of that key during the TLS handshake.

Step 7: Decide Where mTLS Belongs

mTLS is especially suitable for controlled service-to-service communication where the organization owns both endpoints and can manage certificate issuance.

It is less suitable as the only authentication approach for a large population of browser users or external customers. Those scenarios normally use techniques such as:

  • OAuth for delegated login
  • Secure authentication cookies for server-rendered web applications
  • Short-lived bearer tokens for Web APIs

A common architecture uses different methods at different boundaries:

Browser user
  |
  | OAuth login
  | secure cookie or bearer token
  v
Public frontend or API
  |
  | mTLS for internal service identity
  v
Internal dispatch service

The internal service can still apply claims-based authorization after authenticating the certificate.

Step 8: Protect the Private Keys During Operation

mTLS depends on private-key secrecy. If an attacker obtains the route worker's private key and certificate, they can impersonate that workload until the certificate is rejected or expires.

Apply these controls:

  • Store .pfx files only in protected certificate stores or runtime secret mounts.
  • Do not commit certificates or passwords to source control.
  • Do not include certificates in container image layers.
  • Mount certificate files read-only.
  • Restrict which workload can access each mounted secret.
  • Use separate certificates for separate clients.
  • Replace certificates before they expire.
  • Stop trusting a certificate that has been revoked or replaced.
  • Never log certificate passwords or private-key material.

TLS protects data in transit. It does not protect a private key stored carelessly on disk or inside an image registry.

Testing the mTLS Boundary

Test failure paths as deliberately as successful calls.

Valid client

Provide the approved route-worker certificate and verify that the protected endpoint returns its expected success status.

Missing certificate

Call the endpoint without a client certificate. Kestrel should reject the secure connection before the controller executes.

Untrusted certificate

Use a certificate whose chain is not trusted by the server. The TLS connection should fail.

Trusted but unauthorized certificate

Use a technically valid certificate with the wrong issuer or client name. ASP.NET Core certificate validation should fail.

Expired certificate

Use an expired certificate and verify that validation does not succeed.

Wrong server identity

Call the service with a hostname that does not match the server certificate. The client should reject the connection.

Authorization policy failure

Authenticate a certificate that creates a principal without the required claim and verify that the protected endpoint returns a forbidden result.

These tests confirm three separate boundaries:

  1. TLS establishes an encrypted connection.
  2. Certificate authentication identifies an approved caller.
  3. Authorization decides which operations that caller may execute.

Common Mistakes

Reusing one certificate for every service

The server cannot distinguish clients precisely, and replacing one compromised identity affects every caller.

Baking the PFX file into the image

The private key becomes part of the image history and every copy of the image.

Requiring a certificate but accepting any valid issuer

Certificate-chain validity proves technical trust, not application authorization. Validate the expected issuer and client identity.

Disabling server certificate validation in HttpClient

The client loses proof that it reached the intended server.

Treating mTLS as authorization

mTLS identifies the connection peer. The application still needs policies that decide what the identity may do.

Hardcoding certificate passwords

A protected file with a password written in the same codebase is not meaningfully separated from its secret.

Sending traffic through plain HTTP inside the cluster

Internal traffic can cross shared infrastructure. A zero-trust design protects each connection rather than assuming the network is safe.

Forgetting certificate expiration

A deployment can fail suddenly when either endpoint presents an expired certificate. Track expiry and replace certificates through a controlled process.

Migration Checklist

  • [ ] Identify every client using the shared API key
  • [ ] Issue a dedicated server certificate for the API hostname
  • [ ] Issue a separate client certificate for each approved workload
  • [ ] Package private certificates as protected .pfx files
  • [ ] Remove certificates and passwords from source control
  • [ ] Mount certificates at runtime from a secret store
  • [ ] Configure Kestrel to use HTTPS
  • [ ] Require client certificates on the internal endpoint
  • [ ] Validate the certificate chain, issuer, and expected client name
  • [ ] Convert the validated identity into minimal claims
  • [ ] Protect operations with authorization policies
  • [ ] Configure HttpClient with the caller's certificate
  • [ ] Keep normal server-certificate validation enabled
  • [ ] Test missing, expired, untrusted, and unauthorized certificates
  • [ ] Remove the shared API-key path after every approved client migrates
  • [ ] Define a certificate replacement and revocation process

Conclusion

Replacing a shared API key with mTLS changes internal authentication from knowledge of one reusable string to proof of possession of a private key tied to a certificate identity.

Kestrel protects the transport and requires the client certificate. ASP.NET Core validates that certificate, creates claims, and applies authorization policies. HttpClient presents the caller's certificate without weakening server validation. Runtime secret mounts keep private keys outside container images.

The result is a service boundary where encryption, workload identity, and operation-level authorization are separate controls instead of one shared secret carrying the entire security burden.

Share:

Comments0

Home Profile Menu Sidebar
Top