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 Period —
NotBefore/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:
- Verifies that the certificate is signed by a trusted CA (chain validation)
- Checks that the certificate has not expired
- Checks that the certificate has not been revoked (CRL / OCSP — optional)
- 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 itRequireCertificate— 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:
- Expiration monitoring — alert 30 days in advance. Azure Key Vault and Application Insights can do this automatically.
- Temporarily accept both certificates — during the transition period, the whitelist contains both the old and the new thumbprint.
- 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).
- 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 = Onlineenabled (or at leastOffline) - ✅
X509KeyStorageFlags.EphemeralKeySeton Linux/containers - ✅ Automatic expiration alert 30 days in advance
- ✅ Transition strategy (whitelist with two thumbprints) on renewal
- ✅
AllowedCertificateTypes = CertificateTypes.Chainedin production (notAll) - ✅ Logging for
OnAuthenticationFailedsent 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.