Safeguard
AppSec

X.509 Certificates in .NET: System.Security.Cryptography Explained

A practitioner's tour of System.Security.Cryptography.X509Certificates: loading certs safely on modern .NET, chain validation, stores, and the mistakes that quietly disable TLS security.

Yukti Singhal
Platform Engineer
7 min read

System.Security.Cryptography.X509Certificates is the .NET namespace for everything certificate-shaped: loading and inspecting X.509 certificates, querying platform certificate stores, building and validating trust chains, and generating certificates and CSRs in code. It is also a namespace where the convenient path and the secure path have historically diverged — enough that .NET 9 obsoleted the most-used constructors in it. This guide covers the types you will actually touch, the .NET 9 loading changes, where System.Security.Cryptography.Pkcs fits in, and the handful of patterns that separate a hardened TLS setup from one that only looks like it.

The cast of types

  • X509Certificate2 — the certificate object you work with everywhere: subject, issuer, validity dates, extensions, and (optionally) an associated private key. Its ancestor X509Certificate survives mainly for legacy signatures; new code should not use it directly.
  • X509Store — access to the platform certificate stores (CurrentUser/LocalMachine, store names like My, Root, CA). On Linux the store model is emulated and rooted in the system CA bundle, so behavior differs across OSes more than most code assumes.
  • X509Chain — chain building and validation: given a leaf, walk to a trusted root, checking signatures, validity periods, revocation, and policy.
  • CertificateRequest — programmatic CSR and certificate generation (self-signed or CA-signed), available since .NET Core 2.0. This is how you mint test certificates in code instead of shelling out to openssl.
  • X509Certificate2Collection — bags of certificates, with Find methods for thumbprint, subject, and so on.

Loading certificates: the .NET 9 change you must know

For years, the idiom was new X509Certificate2(bytes) or new X509Certificate2(path, password). Those constructors are content-sniffing: they accept DER, PEM-ish content, PKCS#7, or PKCS#12/PFX and decide what you gave them. That flexibility is a security problem — attacker-supplied "certificate" bytes could be a full PKCS#12 payload (with key material and import side effects) where the code only intended to parse a public certificate.

Starting in .NET 9, the byte-array, span, and file-path constructors on X509Certificate and X509Certificate2 are obsolete (compiler warning SYSLIB0057). The replacement is the intent-explicit X509CertificateLoader:

using System.Security.Cryptography.X509Certificates;

// Exactly one certificate, X.509 DER only — will not silently accept PFX
X509Certificate2 cert = X509CertificateLoader.LoadCertificate(derBytes);
X509Certificate2 fromFile = X509CertificateLoader.LoadCertificateFromFile("server.cer");

// PKCS#12/PFX, stated explicitly
X509Certificate2 pfx = X509CertificateLoader.LoadPkcs12FromFile(
    "server.pfx", password, X509KeyStorageFlags.EphemeralKeySet);

You choose LoadCertificate or LoadPkcs12 (and the collection variants) up front; mismatched content fails instead of being creatively interpreted. On .NET Framework and .NET Standard, the same API ships in the Microsoft.Bcl.Cryptography package. PEM content has its own dedicated APIs — X509Certificate2.CreateFromPemFile and CreateFromPem — which pair a cert with its PEM-encoded key.

Migration advice: treat SYSLIB0057 as a real finding, not noise to suppress. Every suppressed call site is a place where content-type confusion remains possible.

Validating chains properly

TLS clients get chain validation from SslStream/HttpClient for free. Where teams write their own validation — mTLS services validating client certificates, webhook signature verification, artifact signing — X509Chain is the tool:

using var chain = new X509Chain();
chain.ChainPolicy.RevocationMode = X509RevocationMode.Online;
chain.ChainPolicy.RevocationFlag = X509RevocationFlag.ExcludeRoot;
chain.ChainPolicy.VerificationFlags = X509VerificationFlags.NoFlag;

bool valid = chain.Build(clientCert);
if (!valid)
{
    foreach (var status in chain.ChainStatus)
        logger.LogWarning("Chain error: {Status} {Info}", status.Status, status.StatusInformation);
}

Points that matter in production:

  • Pinning to a private CA: set chain.ChainPolicy.TrustMode = X509ChainTrustMode.CustomRootTrust and add your root to CustomTrustStore. This validates against only your CA — the correct way to do internal mTLS, and far safer than comparing thumbprints by hand.
  • Revocation is a decision, not a default you can ignore. Online adds latency and an availability dependency on CRL/OCSP endpoints; NoCheck means a compromised-and-revoked certificate still validates. Choose per threat model and document it.
  • Build returning true is the only success signal. Checking chain.ChainStatus.Length == 0 after ignoring the return value is a recurring bug.

The anti-pattern that undoes everything

The most damaging certificate code in the .NET ecosystem is one line:

// NEVER ship this
handler.ServerCertificateCustomValidationCallback = (msg, cert, chain, errors) => true;

Accepting all server certificates turns TLS into unauthenticated encryption — any on-path attacker can present any certificate and read the traffic. It gets committed as a "temporary" fix for internal endpoints with private CAs, then ships. The correct fixes are: install the private root into the trust store, or use CustomRootTrust validation as above, or (narrowest case) compare against a known certificate explicitly. Static analyzers flag this pattern, and it is worth a zero-tolerance policy in code review; runtime testing with a DAST tool against a proxy can also catch services that accept forged certificates in practice.

The sibling anti-patterns: X509VerificationFlags.AllowUnknownCertificateAuthority in production code, and mTLS "validation" that only checks the subject string without building the chain.

Where System.Security.Cryptography.Pkcs fits

System.Security.Cryptography.Pkcs (shipped as a NuGet package of the same name) covers the PKCS standards that surround certificates rather than the certificates themselves:

  • SignedCms — PKCS#7/CMS detached and attached signatures: verifying signed payloads, code-signing-style flows, S/MIME building blocks.
  • EnvelopedCms — CMS encryption to certificate recipients.
  • Pkcs12Builder / Pkcs12Info — constructing and inspecting PFX files with control over encryption and MAC parameters, instead of accepting X509Certificate2 export defaults.
  • Rfc3161TimestampRequest — trusted timestamping, the piece that keeps signatures verifiable after the signing certificate expires.

A useful security note: when verifying CMS signatures with SignedCms.CheckSignature, understand that it verifies the cryptographic signature — deciding whether the signer's certificate chains to a CA you trust is your job, via X509Chain. Verification without a trust decision is half a control.

Operational hygiene: keys, stores, expiry

  • Prefer EphemeralKeySet when loading PFX files you do not need persisted; the default behavior can write key files to disk (user profile or machine key store) that outlive the process and become audit findings.
  • Never load certificates with private keys from world-readable paths or bake PFX files into container images. Mount them at runtime from a secrets manager; an image containing server.pfx plus its password in an env var is a common finding in image scans — the kind of secrets-in-artifact issue SCA and container scanning surfaces automatically. Safeguard, for instance, flags private key material discovered in image layers for exactly this reason.
  • Watch expiry programmatically. cert.NotAfter is trivially queryable; a scheduled check that alerts 30 days out is an hour of work and prevents the most common self-inflicted outage in TLS operations.
  • Cross-platform stores differ. StoreName.Root on Linux maps onto the system CA bundle semantics, and writing to stores may behave differently than on Windows. If your product ships cross-platform, test store interactions on each OS rather than extrapolating from Windows.

FAQ

What replaced the X509Certificate2 constructors in .NET 9?

X509CertificateLoader, with explicit methods per content type: LoadCertificate/LoadCertificateFromFile for X.509 content and LoadPkcs12/LoadPkcs12FromFile (plus collection variants) for PFX. The old byte/path constructors trigger warning SYSLIB0057 because they guessed the content format, which enabled content-type confusion.

What is the difference between System.Security.Cryptography.X509Certificates and System.Security.Cryptography.Pkcs?

The X509Certificates namespace handles certificates, stores, and chain validation. The Pkcs package handles the surrounding message formats — CMS/PKCS#7 signing (SignedCms), encryption (EnvelopedCms), and PKCS#12 construction (Pkcs12Builder).

How do I validate a certificate against my own private CA in .NET?

Use X509Chain with ChainPolicy.TrustMode = X509ChainTrustMode.CustomRootTrust and add your root certificate to ChainPolicy.CustomTrustStore. This validates the full chain against only your CA, unlike thumbprint comparisons or subject-string checks.

Is returning true from ServerCertificateCustomValidationCallback ever acceptable?

Only in throwaway local development, and even then a custom trust store is better. In any shipped code it disables server authentication entirely, allowing man-in-the-middle interception of the connection.

Never miss an update

Weekly insights on software supply chain security, delivered to your inbox.