RO EN

Authentication with X.509 Certificate in ASP.NET Core

Authentication with X.509 Certificate in ASP.NET Core
Doru Bulubașa
22 April 2026

Passwords are a risk. Tokens can be stolen. X.509 client certificates provide an authentication mechanism based on public key cryptography — no shared secrets, no passwords that can leak, no tokens that expire inconveniently.

It is the preferred mechanism for machine-to-machine (M2M) communication, internal APIs, and scenarios where you want to completely eliminate the human factor from authentication. In the next article, we will see how it extends to mTLS — but first, let's understand the fundamentals.


1. What is an X.509 certificate?

An X.509 certificate is a standardized digital document that binds a public key to an identity (person, server, application). The certificate is signed by a Certification Authority (CA) — either a public one (DigiCert, Let's Encrypt) or a private one (your organization's internal CA).

The essential structure of a certificate:

  • Subject — the identity for which it is issued (CN=myservice, O=MyCompany)
  • Issuer — who signed it (the CA)
  • Public Key — the associated public key
  • Validity PeriodNotBefore / NotAfter
  • Serial Number — unique identifier per CA
  • Thumbprint — SHA-1 or SHA-256 hash of the entire certificate (used for quick identification)
  • Extensions — Key Usage, Extended Key Usage (EKU), Subject Alternative Names (SAN)

How authentication works

The client presents the certificate during the TLS handshake. The server:

  1. Verifies that the certificate is signed by a trusted CA (chain validation)
  2. Checks that the certificate has not expired
  3. Checks that the certificate has not been revoked (CRL / OCSP — optional)
  4. Extracts the identity from the certificate (Subject, SAN, thumbprint) and maps it to a user/service

The private key never leaves the client. The server verifies the identity without knowing it — this is the essence of public key cryptography.


2. Types of certificates and file formats

Before any code, it's worth knowing the formats you'll work with:

Format Extension Content Used for
PEM .pem, .crt, .cer Base64, readable text Public certificate, CA chain
DER .der, .cer Binary Public certificate (Windows)
PFX / PKCS#12 .pfx, .p12 Binary, password protected Certificate + private key together
Separate PEM .pem + .key Two text files Linux/macOS, OpenSSL, Nginx

In the .NET world, you will mostly work with .pfx (contains everything) or with the Certificate Store (Windows/macOS). On Linux in containers, you will most often import from PEM files.


3. Creating certificates for development

You don't need a real CA to test locally. You create a self-signed CA and from it issue client certificates.

3.1 Create private CA (root)

# Generate the CA's private key
openssl genrsa -out ca.key 4096

# Create the CA's root certificate (self-signed, valid for 10 years)
openssl req -new -x509 -days 3650 -key ca.key -out ca.crt \
  -subj "/CN=MyDev CA/O=MyCompany/C=RO"

3.2 Create client certificate signed by CA

# Generate the client's private key
openssl genrsa -out client.key 2048

# Generate CSR (Certificate Signing Request)
openssl req -new -key client.key -out client.csr \
  -subj "/CN=my-service/O=MyCompany/C=RO"

# CA signs the client certificate (valid 1 year)
openssl x509 -req -days 365 \
  -in client.csr \
  -CA ca.crt -CAkey ca.key -CAcreateserial \
  -out client.crt

# Package the certificate + private key into a .pfx
openssl pkcs12 -export \
  -in client.crt -inkey client.key \
  -out client.pfx \
  -passout pass:dev-password

3.3 Alternative: .NET CLI with dotnet dev-certs

For simple development scenarios, .NET offers a shortcut:

# Generate dev HTTPS certificate (not ideal for client cert auth, but works for tests)
dotnet dev-certs https --export-path ./dev-cert.pfx --password dev-password --trust

4. Server configuration — Kestrel

ASP.NET Core with Kestrel must be explicitly configured to request client certificates. There are three modes:

  • NoCertificate — does not request at all (default)
  • AllowCertificate — accepts if provided, but does not require it
  • RequireCertificate — rejects the connection if no certificate is present

4.1 Configuration in Program.cs

builder.WebHost.ConfigureKestrel(options =>
{
    options.ConfigureHttpsDefaults(https =>
    {
        // Require client certificate — reject if missing
        https.ClientCertificateMode = ClientCertificateMode.RequireCertificate;

        // Custom validation: accept self-signed / internal CA certificates
        https.ClientCertificateValidation = (certificate, chain, errors) =>
        {
            // In production validate the full chain
            // In dev you can return true to accept any certificate
            if (builder.Environment.IsDevelopment())
                return true;

            return errors == SslPolicyErrors.None;
        };
    });
});

4.2 Configuration in appsettings.json

{
  "Kestrel": {
    "Endpoints": {
      "Https": {
        "Url": "https://localhost:5001",
        "Certificate": {
          "Path": "server.pfx",
          "Password": "server-password"
        },
        "ClientCertificateMode": "RequireCertificate"
      }
    }
  }
}

Important note: If your application runs behind a reverse proxy (Nginx, Azure Application Gateway, IIS), the client certificate is terminated at the proxy. The ASP.NET Core server does not see it directly — the proxy must be configured to forward the certificate via a header (e.g., X-ARR-ClientCert). We will detail this in the article about mTLS.


5. Authentication configuration in ASP.NET Core

5.1 Install package

dotnet add package Microsoft.AspNetCore.Authentication.Certificate

5.2 Configuration in Program.cs

builder.Services
    .AddAuthentication(CertificateAuthenticationDefaults.AuthenticationScheme)
    .AddCertificate(options =>
    {
        // What types of certificates we accept
        options.AllowedCertificateTypes = CertificateTypes.All;
        // CertificateTypes.SelfSigned    — only self-signed
        // CertificateTypes.Chained       — only CA-signed certificates
        // CertificateTypes.All           — both

        // Revocation check (recommended in production)
        options.RevocationMode = X509RevocationMode.Online; // or NoCheck in dev

        // Custom validation — map certificate to ClaimsPrincipal
        options.Events = new CertificateAuthenticationEvents
        {
            OnCertificateValidated = context =>
            {
                var certificate = context.ClientCertificate;

                // Extract identity from Subject
                var commonName = certificate.GetNameInfo(
                    X509NameType.SimpleName, forIssuer: false);

                var claims = new[]
                {
                    new Claim(ClaimTypes.NameIdentifier, certificate.Thumbprint),
                    new Claim(ClaimTypes.Name, commonName ?? "unknown"),
                    new Claim("thumbprint", certificate.Thumbprint),
                    new Claim("cert-subject", certificate.Subject)
                };

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

                context.Success();
                return Task.CompletedTask;
            },

            OnAuthenticationFailed = context =>
            {
                context.Fail("Certificate authentication failed: "
                    + context.Exception.Message);
                return Task.CompletedTask;
            }
        };
    });

builder.Services.AddAuthorization();

// IMPORTANT: AddCertificateForwarding if you are behind a proxy
// builder.Services.AddCertificateForwarding(options =>
// {
//     options.CertificateHeader = "X-ARR-ClientCert";
// });

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

6. Advanced validation — whitelist and chain validation

In real scenarios, you don't accept any certificate signed by your CA — you want to control exactly which certificates are authorized. Two common strategies:

6.1 Whitelist by Thumbprint

The simplest mechanism: maintain a list of known thumbprints and reject anything else.

OnCertificateValidated = context =>
{
    var allowedThumbprints = context.HttpContext
        .RequestServices
        .GetRequiredService<IConfiguration>()
        .GetSection("Auth:AllowedCertificates")
        .Get<string[]>() ?? [];

    var thumbprint = context.ClientCertificate.Thumbprint;

    if (!allowedThumbprints.Contains(thumbprint,
            StringComparer.OrdinalIgnoreCase))
    {
        context.Fail($"Certificate thumbprint not in whitelist: {thumbprint}");
        return Task.CompletedTask;
    }

    // Valid certificate — build Principal
    var claims = new[]
    {
        new Claim(ClaimTypes.NameIdentifier, thumbprint),
        new Claim(ClaimTypes.Name,
            context.ClientCertificate.GetNameInfo(
                X509NameType.SimpleName, false) ?? "")
    };

    context.Principal = new ClaimsPrincipal(
        new ClaimsIdentity(claims, context.Scheme.Name));
    context.Success();
    return Task.CompletedTask;
}
// appsettings.json
{
  "Auth": {
    "AllowedCertificates": [
      "A1B2C3D4E5F6...",
      "1234567890AB..."
    ]
  }
}

6.2 Validation by internal CA

More scalable: accept any certificate issued by your internal CA, without managing a list.

OnCertificateValidated = context =>
{
    var certificate = context.ClientCertificate;

    // Load trusted CA
    var trustedCa = new X509Certificate2("ca.crt");

    var chain = new X509Chain();
    chain.ChainPolicy.TrustMode = X509ChainTrustMode.CustomRootTrust;
    chain.ChainPolicy.CustomTrustStore.Add(trustedCa);
    chain.ChainPolicy.RevocationMode = X509RevocationMode.NoCheck; // or Online
    chain.ChainPolicy.VerificationFlags = X509VerificationFlags.AllowUnknownCertificateAuthority;

    var isValid = chain.Build(certificate);

    if (!isValid)
    {
        var errors = string.Join(", ",
            chain.ChainStatus.Select(s => s.StatusInformation));
        context.Fail($"Chain validation failed: {errors}");
        return Task.CompletedTask;
    }

    var claims = new[]
    {
        new Claim(ClaimTypes.NameIdentifier, certificate.Thumbprint),
        new Claim(ClaimTypes.Name,
            certificate.GetNameInfo(X509NameType.SimpleName, false) ?? "")
    };

    context.Principal = new ClaimsPrincipal(
        new ClaimsIdentity(claims, context.Scheme.Name));
    context.Success();
    return Task.CompletedTask;
}

7. Client — how to send the certificate from another .NET service

Usually, authentication with X.509 certificates is used in M2M communication — one .NET service calls another. Here's how to configure HttpClient to send the certificate:

// Option 1: from .pfx file
var certificate = new X509Certificate2(
    "client.pfx",
    "dev-password",
    X509KeyStorageFlags.MachineKeySet);

var handler = new HttpClientHandler();
handler.ClientCertificates.Add(certificate);

var client = new HttpClient(handler)
{
    BaseAddress = new Uri("https://api.myservice.com")
};
// Option 2: from Certificate Store (Windows/macOS)
using var store = new X509Store(StoreName.My, StoreLocation.CurrentUser);
store.Open(OpenFlags.ReadOnly);

var certificates = store.Certificates.Find(
    X509FindType.FindByThumbprint,
    "A1B2C3D4E5F6...",
    validOnly: true);

if (certificates.Count == 0)
    throw new InvalidOperationException("Client certificate not found in store.");

var handler = new HttpClientHandler();
handler.ClientCertificates.Add(certificates[0]);

var client = new HttpClient(handler);

7.1 Integration with IHttpClientFactory (recommended)

// Program.cs
builder.Services.AddHttpClient("SecureApiClient", client =>
{
    client.BaseAddress = new Uri("https://api.myservice.com");
})
.ConfigurePrimaryHttpMessageHandler(() =>
{
    var cert = new X509Certificate2(
        builder.Configuration["Certificates:ClientCertPath"]!,
        builder.Configuration["Certificates:ClientCertPassword"]);

    var handler = new HttpClientHandler();
    handler.ClientCertificates.Add(cert);
    return handler;
});
// Injection and usage
public class MyService(IHttpClientFactory httpClientFactory)
{
    public async Task<string> CallSecureApiAsync()
    {
        var client = httpClientFactory.CreateClient("SecureApiClient");
        var response = await client.GetAsync("/api/data");
        response.EnsureSuccessStatusCode();
        return await response.Content.ReadAsStringAsync();
    }
}

8. Certificates in Azure — production scenarios

8.1 Storage in Azure Key Vault

In production, you never store .pfx files on disk or in appsettings.json. You put them in Azure Key Vault:

dotnet add package Azure.Security.KeyVault.Certificates
dotnet add package Azure.Identity
// Read certificate from Key Vault at startup
var keyVaultUrl = builder.Configuration["KeyVault:Url"]!;
var credential  = new DefaultAzureCredential();
var certClient  = new CertificateClient(new Uri(keyVaultUrl), credential);
var secretClient = new SecretClient(new Uri(keyVaultUrl), credential);

// Key Vault stores the PFX as a secret (base64)
var secret = await secretClient.GetSecretAsync("client-cert");
var pfxBytes = Convert.FromBase64String(secret.Value.Value);
var certificate = new X509Certificate2(pfxBytes, (string?)null,
    X509KeyStorageFlags.MachineKeySet | X509KeyStorageFlags.PersistKeySet);

// Register in DI
builder.Services.AddSingleton(certificate);

8.2 Azure App Service — upload certificate

On Azure App Service you can upload a certificate directly in the portal (Certificates → Bring your own certificates) and access it by thumbprint:

// appsettings.json
{
  "WEBSITE_LOAD_CERTIFICATES": "*"  // or specific thumbprint
}
// Read from Certificate Store on App Service
using var store = new X509Store(StoreName.My, StoreLocation.CurrentUser);
store.Open(OpenFlags.ReadOnly);

var thumbprint = builder.Configuration["Certificates:ClientThumbprint"]!;
var certs = store.Certificates.Find(
    X509FindType.FindByThumbprint, thumbprint, validOnly: false);

if (certs.Count == 0)
    throw new InvalidOperationException(
        $"Certificate {thumbprint} not found on App Service.");

9. Certificate renewal — without downtime

Expiration of a certificate that is not proactively managed can completely stop a service in production. The recommended strategy:

  1. Expiration monitoring — alert 30 days in advance. Azure Key Vault and Application Insights can do this automatically.
  2. Temporarily accept both certificates — during the transition period, the whitelist contains both the old and the new thumbprint.
  3. Deploy new certificate — update the secret in Key Vault or App Service, without restart if you read the certificate on each request (or use a reload strategy).
  4. Remove the old certificate — after all client services have migrated.
// Strategy: reload certificate on each request (expensive but simple)
// Better: IOptionsMonitor<T> with certificates reloaded from Key Vault periodically

public class CertificateProvider(IConfiguration config)
{
    private X509Certificate2? _cached;
    private DateTime _loadedAt;

    public X509Certificate2 GetCertificate()
    {
        // Re-read certificate from Key Vault once per hour
        if (_cached != null &&
            DateTime.UtcNow - _loadedAt < TimeSpan.FromHours(1))
        {
            return _cached;
        }

        // ... read from Key Vault
        _loadedAt = DateTime.UtcNow;
        return _cached!;
    }
}

10. Common problems and their solutions

Problem Likely cause Solution
403 even though certificate is present Authentication succeeded, but authorization failed Check that context.Success() is called and that ClaimsPrincipal is populated correctly
Certificate ignored behind a proxy TLS terminated at proxy, request arrives HTTP to ASP.NET Configure AddCertificateForwarding with the proxy's header
CryptographicException on PFX import Wrong flags on Linux/container Add X509KeyStorageFlags.EphemeralKeySet on Linux
Chain validation fails CA is not in trusted store Use CustomRootTrust and add the CA manually to CustomTrustStore
Certificate expired in production Lack of monitoring Alert in Azure Key Vault / Application Insights 30+ days before expiration
SslPolicyErrors.RemoteCertificateChainErrors Dev server with self-signed cert In dev: handler.ServerCertificateCustomValidationCallback = HttpClientHandler.DangerousAcceptAnyServerCertificateValidator — NEVER in production

11. Production checklist

  • ✅ Certificates stored in Azure Key Vault or Certificate Store, not on disk
  • ✅ PFX password in Key Vault Secret, not in appsettings.json
  • RevocationMode = Online enabled (or at least Offline)
  • X509KeyStorageFlags.EphemeralKeySet on Linux/containers
  • ✅ Automatic expiration alert 30 days in advance
  • ✅ Transition strategy (whitelist with two thumbprints) on renewal
  • AllowedCertificateTypes = CertificateTypes.Chained in production (not All)
  • ✅ Logging for OnAuthenticationFailed sent to Application Insights
  • ✅ Tested with expired and revoked certificates
  • ✅ Reverse proxy configured to forward client certificate if TLS is terminated at proxy

Conclusion

Authentication with X.509 certificates eliminates entire classes of vulnerabilities associated with passwords and tokens: there is nothing to steal from a database, no shared secrets, no simple replay attacks. The private key always remains with the client.

The real complexity is not in the authentication code — which, as you have seen, is relatively compact — but in managing the certificate lifecycle: issuance, distribution, renewal, and revocation. Investing in automating these processes pays off quickly.

In the next article, we take the logical next step: mTLS (mutual TLS) — the scenario where both client and server authenticate each other via certificates, widely used in microservices architectures and service mesh.