Mert Özen Further With Every Line
Security Cryptography for Developers — #06

X.509 and PKI for Developers: Loading Certificates, Chain Validation and mTLS in ASP.NET Core

Mert Özen Nov 14, 2026 13 min 2 views
X.509 and PKI for Developers: Loading Certificates, Chain Validation and mTLS in ASP.NET Core

Trusting a certificate means validating the chain up to your own trust anchor and checking the EKU and host name separately; even with a valid chain, a name mismatch is caught by MatchesHostname, not X509Chain. With real outputs and tests we cover X509CertificateLoader, .NET 10's SHA-256 thumbprint search and PKCS#12 algorithm choice, CustomRootTrust validation and SAN-based mTLS between Bookstore's services.

Version note: The code in this post was built and run with .NET 10, ASP.NET Core 10, Microsoft.AspNetCore.Authentication.Certificate 10.0.15 and xUnit v3 4.0; the outputs were taken on Ubuntu 24.04 (OpenSSL 3.0).

Guess First

Bookstore's order flow has been split into two services: Ordering takes the order and calls an internal endpoint on Bookstore.Api over TLS to reserve stock. Because it's the internal network, the first version turned off the server certificate check on the client side, and on the server side the incoming client certificate is only checked for "is there a certificate at all". The tests are green and the requests return 201.

What have we proven with this setup? Nothing. A certificate is a signed document that says who a public key belongs to; if you don't check who signed it, what job it was issued for, its validity period and its name, all you have is an encrypted connection with "somebody". In the previous post we verified signatures with a public key. In this post we build, in code, the structure that proves who owns those public keys: what's inside an X.509 certificate, chain validation, the traps in validation callbacks, and mTLS between Bookstore's services.

The operational side of certificates, getting, renewing and monitoring them, belongs to the SSL/TLS Certificates and HTTPS series; that series' mTLS post also covers the Kestrel and Nginx setup in detail. Here we focus on the validation code a developer writes.

What's Inside a Certificate?

For the examples we set up our own small PKI: a root CA, an intermediate CA signed by it, and service certificates signed by the intermediate. Bookstore, the online bookstore used as the shared example across this site's series, keeps this code in the Pki folder of the Bookstore.Crypto library. A service certificate is issued like this:

public X509Certificate2 Issue(string commonName, string[] dnsNames, Oid eku, DateTimeOffset notBefore, DateTimeOffset notAfter)
{
    using ECDsa key = ECDsa.Create(ECCurve.NamedCurves.nistP256);
    CertificateRequest request = new($"CN={commonName}, O=Bookstore", key, HashAlgorithmName.SHA256);
    request.CertificateExtensions.Add(new X509BasicConstraintsExtension(false, false, 0, true));
    request.CertificateExtensions.Add(new X509KeyUsageExtension(X509KeyUsageFlags.DigitalSignature, true));
    request.CertificateExtensions.Add(new X509EnhancedKeyUsageExtension([eku], false));
    SubjectAlternativeNameBuilder san = new();
    foreach (string dnsName in dnsNames)
    {
        san.AddDnsName(dnsName);
    }

    request.CertificateExtensions.Add(san.Build());
    request.CertificateExtensions.Add(new X509SubjectKeyIdentifierExtension(request.PublicKey, false));
    request.CertificateExtensions.Add(X509AuthorityKeyIdentifierExtension.CreateFromCertificate(Intermediate, true, false));

    using X509Certificate2 certificate = request.Create(Intermediate, notBefore, notAfter, NewSerialNumber());
    return certificate.CopyWithPrivateKey(key);
}

The pki-init command writes the certificates and keys to a certs/ folder that git ignores, and cert-info reads the API's certificate:

$ dotnet run --project src/Bookstore.CryptoLab -- cert-info
Subject      : CN=api.bookstore.test, O=Bookstore
Issuer       : CN=Bookstore Dev Services CA, O=Bookstore
Serial       : 4916A2CE...
Valid        : 2026-11-14 02:53 .. 2026-12-14 02:58 UTC
Key          : ECC 256 bits, signed with sha256ECDSA
  X509v3 Basic Constraints   (critical) CA=False
  X509v3 Key Usage           (critical) DigitalSignature
  X509v3 Extended Key Usage             TLS Web Server Authentication
  X509v3 Subject Alternative Name            DNS: api.bookstore.test, localhost
  X509v3 Subject Key Identifier            041448D5B8C99022...
  X509v3 Authority Key Identifier            30168014DF1D9805...
Thumbprint SHA-1  : D467920CC13B318618196AD219F78A2E7837EAD8
Thumbprint SHA-256: 3161218CF3954831F86549CBE8CAF214F5B3091F087D0A978EC5331F9AC5532B
api.crt.pem holds 2 certificates; FindByThumbprint(SHA256) -> 1 match

These are the fields your validation code looks at. Basic Constraints says whether the certificate may act as a CA; a service certificate must not sign other certificates. Key Usage says what the key can technically do (here, sign), and EKU says which role the certificate may be used in: server identity or client identity. The SAN is where host name checks look; the CN hasn't been used for that job for a long time. The Subject and Authority Key Identifiers speed up the "which key signed this certificate" question while building a chain.

The thumbprint lines show a .NET 10 addition. The Thumbprint property uses SHA-1; X509Certificate2Collection.FindByThumbprint(HashAlgorithmName, string) arrived in .NET 10 and can search by a SHA-256 thumbprint. The old Find(X509FindType.FindByThumbprint, ...) only looks at SHA-1. If you point at a certificate by thumbprint in config, use SHA-256 in new code.

Loading Certificates: X509CertificateLoader and PEM

Since .NET 9, the recommended way to load certificates is X509CertificateLoader. The old constructors now produce a warning:

$ dotnet build
Program.cs(4,30): warning SYSLIB0057: 'X509Certificate2.X509Certificate2(byte[])' is obsolete: 'Loading certificate data through the constructor or Import is obsolete. Use X509CertificateLoader instead to load certificates.' (https://aka.ms/dotnet-warnings/SYSLIB0057)

The reason is that the old constructor guesses the format of the bytes it receives: where you expected a certificate, a PKCS#12 file could load too, which means an unexpected file processed along an unexpected path. The new loader asks you what you expect: LoadCertificate accepts only a single certificate, and when I gave it PKCS#12 bytes it refused with ASN1 corrupted data; for PKCS#12 there's a separate LoadPkcs12. We cover migrating old code as a whole in the last post of the series.

In the Linux world the certificate and key usually come as two separate PEM files. X509Certificate2.CreateFromPemFile(certPath, keyPath) combines them. Windows has one difference: a key loaded from PEM is ephemeral, a key that was never written to disk, and Schannel, the Windows TLS stack, can't use it. Microsoft's SslStream troubleshooting page documents this with the No credentials are available in the security package error. That's why Kestrel's own PEM loader converts the certificate to PKCS#12 and reloads it on Windows, and our helper does the same:

public static X509Certificate2 LoadFromPem(string certificatePath, string keyPath)
{
    X509Certificate2 certificate = X509Certificate2.CreateFromPemFile(certificatePath, keyPath);
    if (!OperatingSystem.IsWindows())
    {
        return certificate;
    }

    using (certificate)
    {
        return X509CertificateLoader.LoadPkcs12(certificate.Export(X509ContentType.Pkcs12), null);
    }
}

Exporting a PKCS#12 file, called PFX on Windows, also involves a choice in .NET 10. The old Export(X509ContentType.Pkcs12, password) uses 3DES and SHA-1 to stay compatible with the oldest readers; the new ExportPkcs12 lets you pick the algorithm. I produced both and inspected them with openssl pkcs12 -info (the password was generated with openssl rand on each run and passed as an environment variable):

cert.Export(X509ContentType.Pkcs12, password)
cert.ExportPkcs12(Pkcs12ExportPbeParameters.Pbes2Aes256Sha256, password)
== ordering-default.pfx
MAC: sha1, Iteration 2000
PKCS7 Encrypted data: pbeWithSHA1And3-KeyTripleDES-CBC, Iteration 2000
Shrouded Keybag: pbeWithSHA1And3-KeyTripleDES-CBC, Iteration 2000
== ordering-aes.pfx
MAC: sha256, Iteration 2000
PKCS7 Encrypted data: PBES2, PBKDF2, AES-256-CBC, Iteration 2000, PRF hmacWithSHA256
Shrouded Keybag: PBES2, PBKDF2, AES-256-CBC, Iteration 2000, PRF hmacWithSHA256

If only modern systems will read the file, choose Pbes2Aes256Sha256. Both outputs use 2000 iterations, a reminder that you shouldn't treat the password as protection on its own. Store a PFX file like a secret.

Chain Building and the Trust Anchor

Trusting a certificate means following the chain of signatures to a root you trust, the trust anchor. In .NET, X509Chain does this and by default uses the operating system's trust store. For internal services that's the wrong scope: we don't want a certificate from any of the hundreds of public CAs the machine trusts to get into our internal API. Our validator trusts only our own root:

public CertificateCheck Validate(X509Certificate2 certificate, Oid requiredUsage, string? expectedDnsName, DateTime? at = null)
{
    using X509Chain chain = new();
    X509ChainPolicy policy = chain.ChainPolicy;
    policy.TrustMode = X509ChainTrustMode.CustomRootTrust;
    policy.CustomTrustStore.AddRange(trustAnchors);
    policy.ExtraStore.AddRange(intermediates);
    policy.ApplicationPolicy.Add(requiredUsage);
    policy.RevocationMode = RevocationMode;
    policy.DisableCertificateDownloads = true;
    if (at is not null)
    {
        policy.VerificationTime = at.Value;
    }

    bool chainOk = chain.Build(certificate);
    List<string> problems = [.. chain.ChainStatus.Select(s => s.Status.ToString()).Distinct()];

    // The chain says nothing about names: the host name is a separate check
    if (expectedDnsName is not null && !certificate.MatchesHostname(expectedDnsName))
    {
        problems.Add($"NameMismatch({expectedDnsName})");
    }

    List<string> elements = [.. chain.ChainElements.Select(e => e.Certificate.GetNameInfo(X509NameType.SimpleName, false))];
    return new CertificateCheck(chainOk && problems.Count == 0, problems, elements);
}

ApplicationPolicy adds the EKU check to the chain, ExtraStore provides the intermediates without trusting them, and DisableCertificateDownloads stops chain building from going out to the internet through the certificate's AIA field. The chain command tries the same validator with seven cases:

$ dotnet run --project src/Bookstore.CryptoLab -- chain
valid server certificate    : OK  []  chain: api.bookstore.test > Bookstore Dev Services CA > Bookstore Dev Root CA
expired                     : REJECTED  [NotTimeValid]  chain: api.bookstore.test > Bookstore Dev Services CA > Bookstore Dev Root CA
client cert used as server  : REJECTED  [NotValidForUsage]  chain: bookstore-ordering > Bookstore Dev Services CA > Bookstore Dev Root CA
issued by another root      : REJECTED  [PartialChain]  chain: api.bookstore.test > Bookstore Dev Services CA
intermediate not available  : REJECTED  [PartialChain]  chain: api.bookstore.test
wrong host name             : REJECTED  [NameMismatch(admin.bookstore.test)]  chain: api.bookstore.test > Bookstore Dev Services CA
revocation Online, no CRL   : REJECTED  [RevocationStatusUnknown, OfflineRevocation]  chain: api.bookstore.test > Bookstore Dev Services CA > Bookstore Dev Root CA

Each line is a lesson of its own. Ordering's client certificate is a perfectly valid chain, but its EKU is client identity, so in the server role it's rejected with NotValidForUsage. A certificate from another PKI ends as PartialChain because it can't reach our root, even when we hold its intermediate. In the "wrong host name" line the chain is flawless and ChainStatus is empty; what catches the name mismatch is the MatchesHostname call we added, not X509Chain. If you write your own validation, keep this distinction in mind: a chain says "someone trusted issued this certificate", not "this certificate belongs to the server you're talking to".

Revocation and Short-Lived Certificates

The last line shows revocation. If a certificate's private key leaks, the CA revokes it and publishes that through a CRL or OCSP. Our dev PKI doesn't publish a CRL; in Online mode .NET can't learn the revocation status and stays on the safe side by rejecting. An internal PKI has two options: publish a CRL, or keep certificate lifetimes short enough that revocation isn't needed and use NoCheck. We chose the second, and service certificates are valid for 30 days.

Public certificates are moving the same way. According to the CA/Browser Forum's Baseline Requirements, the maximum lifetime of a public TLS certificate has been 200 days since 15 March 2026; it drops to 100 days on 15 March 2027 and to 47 days on 15 March 2029. Since March 2024 the same document keeps OCSP optional for CAs and requires them to publish CRLs. What this means for a developer: renewing certificates by hand and baking thumbprints into code are no longer sustainable. Find the certificate by name or file path, and load a renewed certificate without a restart.

The Validation Callback: Returning true for Everything

The mistake I see most often on the client side is turning validation off completely to trust an internal CA: returning true from ServerCertificateCustomValidationCallback in every case, or using DangerousAcceptAnyServerCertificateValidator. That means accepting a certificate an attacker made too; the Kestrel post of the SSL/TLS Certificates and HTTPS series shows this with a real run. The right way is to keep the platform's validation on and change only the trusted root:

public static SocketsHttpHandler CreateMtlsHandler(X509Certificate2Collection trustAnchors, X509Certificate2? clientCertificate)
{
    X509ChainPolicy policy = new()
    {
        TrustMode = X509ChainTrustMode.CustomRootTrust,
        RevocationMode = X509RevocationMode.NoCheck,
        DisableCertificateDownloads = true,
    };
    policy.CustomTrustStore.AddRange(trustAnchors);

    return new SocketsHttpHandler
    {
        SslOptions = new SslClientAuthenticationOptions
        {
            CertificateChainPolicy = policy,
            ClientCertificates = clientCertificate is null ? null : [clientCertificate],
        },
    };
}

CertificateChainPolicy only changes the chain; the host name check stays in place. When I connected to the same server with 127.0.0.1 instead of localhost, all three requests failed in TLS because the certificate's SAN has no IP address:

$ dotnet run --project src/Bookstore.CryptoLab -- mtls-call https://127.0.0.1:7443
ordering              : HttpRequestException: The remote certificate is invalid according to the validation procedure: RemoteCertificateNameMismatch
shipping              : HttpRequestException: The remote certificate is invalid according to the validation procedure: RemoteCertificateNameMismatch
no client certificate : HttpRequestException: The remote certificate is invalid according to the validation procedure: RemoteCertificateNameMismatch

If you really need a callback, for example because you also want the certificate to come from a specific intermediate, don't override the platform's decision, add to it: if the SslPolicyErrors value passed to the callback isn't None, return false right away, and only then run your extra check.

mTLS in ASP.NET Core

On the server side Kestrel asks for a client certificate on every connection, and the handler in the Microsoft.AspNetCore.Authentication.Certificate package does the validation. The setup details, and how Kestrel silently rejects a certificate from an internal CA at the TLS layer, are covered in the mTLS post of the SSL/TLS Certificates and HTTPS series; I saw the same result, and without AllowAnyClientCertificate even Ordering's valid certificate dropped the connection. Our setup differs in two points:

builder.WebHost.ConfigureKestrel(kestrel => kestrel.ConfigureHttpsDefaults(https =>
{
    https.ClientCertificateMode = ClientCertificateMode.RequireCertificate;
    // TLS only asks for a certificate; trust is decided by the certificate handler below, against our own root
    https.AllowAnyClientCertificate();
}));

builder.Services.AddAuthentication(CertificateAuthenticationDefaults.AuthenticationScheme)
    .AddCertificate(options =>
    {
        options.ChainTrustValidationMode = X509ChainTrustMode.CustomRootTrust;
        options.CustomTrustStore = trustAnchors;
        options.AdditionalChainCertificates = intermediates;
        options.AllowedCertificateTypes = CertificateTypes.Chained;
        options.RevocationMode = X509RevocationMode.NoCheck; // dev PKI publishes no CRL
        options.Events = new CertificateAuthenticationEvents
        {
            OnCertificateValidated = context =>
            {
                string? service = ServiceNameOf(context.ClientCertificate);
                if (service is null)
                {
                    context.Fail("No bookstore service name in the certificate SAN.");
                    return Task.CompletedTask;
                }

                context.Principal = new ClaimsPrincipal(new ClaimsIdentity(
                    [new Claim(ServiceClaim, service), new Claim(ClaimTypes.Name, service)], context.Scheme.Name));
                context.Success();
                return Task.CompletedTask;
            },
        };
    });

builder.Services.AddAuthorizationBuilder()
    .SetFallbackPolicy(new AuthorizationPolicyBuilder().RequireAuthenticatedUser().Build())
    .AddPolicy("OrderingService", policy => policy.RequireClaim(ServiceClaim, "ordering"));

First, we take the service identity from the ordering.bookstore.test name in the SAN, not from the CN: ServiceNameOf accepts only DNS names ending in .bookstore.test. Second, we keep authentication and authorization apart: the Shipping service's certificate is from the same CA and valid, but stock reservation is open only to Ordering. The fallback policy also closes the door that AllowAnyClientCertificate opens: no unauthenticated request reaches any endpoint. I started the API and ran the lab command playing Ordering:

$ dotnet run --project src/Bookstore.Api &
$ dotnet run --project src/Bookstore.CryptoLab -- mtls-call
ordering              : 201 Created {"reservationId":"01a1d7fe-8fff-7eb1-87e2-5afd783dfd2d","orderId":"0197f001-0000-7000-8000...
shipping              : 403 Forbidden
no client certificate : HttpRequestException: The response ended prematurely. (ResponseEnded)

The request without a certificate doesn't even get an HTTP response. In TLS 1.3 the client certificate is sent in the last step of the handshake, and the client only learns about the server's rejection when the connection closes after it has written its first request; that's why the error says "response ended prematurely" and not a TLS error. Connecting with openssl s_client also showed the server sending the leaf and the intermediate together: Kestrel uses the second certificate in the PEM file as the chain, which is why pki-init appends the intermediate after the leaf.

I pinned these behaviours down with tests. They start the real Kestrel over TLS with a throwaway PKI and lock in four cases: Ordering gets 201, Shipping gets 403, a certificate with the same name from another CA gets 403, and a request without a certificate is cut off with an HttpRequestException:

[Fact]
public async Task Client_certificate_from_another_ca_gets_403()
{
    using DevPki rogue = DevPki.Create(DateTimeOffset.UtcNow.AddMinutes(-10));
    using X509Certificate2 fake = rogue.IssueClient("ordering", DateTimeOffset.UtcNow.AddMinutes(-5), DateTimeOffset.UtcNow.AddDays(1));
    using HttpResponseMessage response = await Reserve(fake);
    Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
}
$ dotnet test
Test run summary: Passed!
  total: 55
  failed: 0
  succeeded: 55
  skipped: 0
  duration: 4s 597ms

If TLS terminates at a reverse proxy, the certificate reaches the application in a header. Then you must trust that header only on requests coming from the proxy; the reverse proxy post of the Web Servers and Hosting series and the mTLS post of the SSL/TLS Certificates and HTTPS series describe that setup.

Production Notes

The root CA's private key is the most valuable part of this structure: with it you can issue a certificate in any service's name. Keep the root offline and leave day-to-day signing to the intermediate. Our DevPki class generates everything in memory and in one place; it's for development and tests only.

Certificates must be renewed before they expire, and a renewed certificate must load without a restart. Kestrel can watch the certificate file in its config and reload it on change; for your own HttpClient's client certificate you have to renew by creating a new SocketsHttpHandler, for example through IHttpClientFactory's handler lifetime. A metric or health check that warns before expiry makes a difference when you move from three-month certificates to 47-day ones.

Log validation failures with their results: PartialChain, NotTimeValid and NotValidForUsage are different problems and the work of different teams. In logs, the certificate's subject, issuer and SHA-256 thumbprint are enough; a PEM file containing a private key must never get into a log, an artifact or a container image. That's why our snapshot's .gitignore keeps certs/, *.pem and *.pfx out.

Common Pitfalls

The first pitfall is the one from Guess First: turning validation off because it's the internal network. The internal network is where an attacker moves around most comfortably once inside; that's exactly where mTLS is worth it.

The second is validating the chain and not the name. If you write your own X509Chain code and don't add MatchesHostname or an equivalent check, any service with a certificate from the same CA can impersonate another service.

The third is reading identity from the CN. The SAN is where host name validation looks; in a system that uses the CN as free text, two fields saying different things causes trouble.

The fourth is not checking the EKU. A validator without an EKU check accepts a client certificate as a server certificate too; the NotValidForUsage line above catches that.

The fifth is concatenating PEM strings without a line break in between. The output of ExportCertificatePem() doesn't end with a newline; when I wrote two outputs directly next to each other the file broke with a -----END CERTIFICATE----------BEGIN CERTIFICATE----- line, and reading it gave ASN1 corrupted data.

A Small Experiment

★☆☆ Add a case to the chain command where the service certificate is signed directly by the root, skipping the intermediate. Is the result valid? Does the intermediate's pathLength: 0 constraint affect this case?

★★☆ Add a setting to ServiceCertificateValidator: accept only certificates signed by a specific intermediate (the SHA-256 thumbprint of the chain's second element must be the expected value). Test that a certificate from another intermediate that chains to the same root is rejected.

★★★ Make Ordering's client certificate renewable: write a small MtlsClientProvider that creates a new SocketsHttpHandler when the certificate file changes, and show in a test that, starting with a certificate that has one minute left and replacing the file with a new one, requests keep going without interruption.

Certificates prove who is who; the data inside the connection is protected by TLS. In the next post we go inside TLS: how the secrets from the handshake turn into traffic keys, AEAD in the record layer and where forward secrecy comes from, and decrypting our own traffic with .NET's key log.