X509Chain: the certificate check that returns False for 25 reasons

A certificate check that answers with a single True or False is almost useless when it says False. The certificate might have expired. The server might have forgotten to send its intermediate. The issuing CA might not be trusted on the machine doing the asking. Those are three different tickets for three different teams, and a boolean cannot tell them apart.

PowerShell can tell them apart, because the classes underneath it already do. X509Certificate2 reads a certificate, X509Chain builds and judges a chain, and SslStream takes a certificate off a live listener without openssl being installed anywhere. Between them they report a named reason and the exact certificate that caused it.

This is a hands-on page. You build a small certificate authority on your own machine in about four steps, then break it one way at a time and watch what each break reports. Every script is given a file name, every section says where to run it, and every output block below is the real output of the script printed directly above it. Nothing here is installed into a certificate store, nothing needs administrator rights, and nothing reaches the network.

Applies to: PowerShell 7 on Windows, Linux and macOS. The chain sections need PowerShell 7 and not Windows PowerShell 5.1, for the reason given in step 1.


Quick answer

If you already have a certificate file and only need the two numbers, these lines are the whole job. Open PowerShell, change the path to your own .cer file, and paste them at the prompt. The rest of this page is the lab that explains what the second block is really telling you.

# Change this path to a .cer file you already have.
$cert = [System.Security.Cryptography.X509Certificates.X509Certificate2]::new('C:\certs\web-prod.cer')
$cert.Subject
$cert.NotAfter
[int]($cert.NotAfter - (Get-Date)).TotalDays

# Build() is the verdict. ChainStatus is the reason, and it is the part worth reading.
$chain = [System.Security.Cryptography.X509Certificates.X509Chain]::new()
$chain.Build($cert)
$chain.ChainStatus.Status
Note: Two things in that block are worth knowing before you rely on it. The subtraction stays in local time, which is correct, and mixing NotAfter with a UTC value instead is the silent arithmetic mistake a whole section below is about. And Build() called straight off the constructor checks revocation online, so it reaches the network; the revocation section explains what to do about that.

What you are about to build, and why

The lab is a miniature internal PKI: a root certificate authority, an issuing CA underneath it, and three server certificates. That is the same shape as a real corporate PKI, which is why the failures it can produce are the failures you meet at work.

What the lab createsWhat it stands for in a real network
A root CA, LAB Root CAThe offline root your organisation trusts, installed once on every machine
An issuing CA, LAB Issuing CAThe CA that signs day-to-day certificates, the one that actually works for a living
A healthy server certificateThe certificate on a web server or an LDAPS endpoint that is doing fine
An expired server certificateThe one nobody renewed
A certificate from a second, unrelated rootA certificate from a CA your machine was never told to trust
A PFX of the healthy certificateThe file with the private key that a server actually presents

Once the lab exists you work through it one problem at a time, and by the end you will be able to do all of the following against real certificates:

  • Read a certificate file and get its subject, issuer, dates, serial and extensions.
  • Say how many days are left, without the arithmetic error that hides an expired certificate.
  • Tell a thumbprint from a file hash, and produce either one deliberately.
  • Validate a chain against a root you name, so the answer does not change from machine to machine.
  • Turn a False verdict into a named reason, and name the certificate that caused it.
  • Pull a certificate off a live TLS listener and decide about it separately from the handshake.
  • Produce a renewal report over a whole folder or a whole certificate store.
Note: The lab is entirely self-contained. It writes into one folder you create, it never imports anything into the Windows certificate store, and the last section deletes the whole thing again. The certificates it generates are worthless to anyone: nothing on a real network trusts them, and nothing should.

Before you start: four steps

These four steps take you from an empty folder to a working lab. Do them in order. If a step does not produce the output shown, stop there: everything after it depends on it.

Step 1. Open PowerShell 7 and confirm the version. This page needs PowerShell 7, whose executable is pwsh.exe. Windows PowerShell 5.1 is a different program called powershell.exe, and Microsoft states that PowerShell 7 "doesn’t replace Windows PowerShell 5.1. It installs to a new directory and runs side-by-side with Windows PowerShell 5.1." So search the Start menu for pwsh rather than for "PowerShell", or run pwsh from any existing prompt.

The reason this page needs 7 is specific: X509ChainPolicy.CustomTrustStore and TrustMode arrived with .NET 5, so they do not exist in Windows PowerShell 5.1. Those two properties are what let a chain check give the same answer on a laptop and on a build agent. Paste this at the prompt to check.

# X509ChainPolicy.CustomTrustStore and TrustMode are .NET 5 and later, so the
# chain sections below need PowerShell 7, not Windows PowerShell 5.1.
'{0,-26}{1}' -f 'PSVersion', $PSVersionTable.PSVersion.ToString()
'{0,-26}{1}' -f 'PSEdition', $PSVersionTable.PSEdition
'{0,-26}{1}' -f 'Runtime', [System.Runtime.InteropServices.RuntimeInformation]::FrameworkDescription
'{0,-26}{1}' -f 'CustomTrustStore present', ([System.Security.Cryptography.X509Certificates.X509ChainPolicy].GetProperty('CustomTrustStore') -ne $null)

You should see a major version of 7 or higher, and True on the last line. On the machine every output on this page was captured from:

PSVersion                 7.4.6
PSEdition                 Core
Runtime                   .NET 8.0.10
CustomTrustStore present  True
Warning: If PSVersion reads 5.1, you are in Windows PowerShell and the last line will read False. The reading and expiry sections will still work; the chain sections will not. Install PowerShell 7 and come back.

Step 2. Create the working folder. Everything on this page lives in one folder. Create it and move into it. The scripts you save here are plain text files, so any editor will do.

# Create the lab folder and move into it. Use any path you like, but
# every example below assumes you are sitting in it.
New-Item -ItemType Directory -Path C:\pki-lab -Force | Out-Null
Set-Location C:\pki-lab

# Confirm where you are. Every script below is run from here.
Get-Location
Note: Save the scripts with any editor: notepad .\Name.ps1 opens Notepad, and code .\Name.ps1 opens VS Code if you have it. Each script on this page is a complete file, so create it, paste the whole block in, save, and run it with .\Name.ps1 from this folder.
Warning: If running a script is blocked by your execution policy, the narrowest fix is the Process scope, which the documentation describes as affecting "only the current PowerShell session" and which is stored in an environment variable rather than in a configuration file, so it disappears when you close the window. Microsoft notes that the LocalMachine scope is the one that needs Run as administrator; this one does not.
# Lasts only as long as this window is open. Nothing is written to
# the registry or to a configuration file, and no elevation is needed.
Set-ExecutionPolicy -Scope Process -ExecutionPolicy RemoteSigned
Note: The scripts below write paths with a forward slash, as in certs/web-prod.cer. That is deliberate and it works on Windows: the PowerShell documentation states that "PowerShell allows you to use backslash or forward slash for compatibility with PowerShell on other platforms", with the caveat that native programs may still want a backslash. Nothing on this page hands a path to a native program.

Step 3. Build the certificates. This is the one long script on the page and you only run it once. It creates a certs subfolder and writes seven files into it. Every date in it is fixed, so your files match the output printed here exactly and the expired certificate stays expired for ever.

Save this as Build-CertLab.ps1 in C:\pki-lab and run it with .\Build-CertLab.ps1.

using namespace System.Security.Cryptography
using namespace System.Security.Cryptography.X509Certificates

# Every date below is fixed, so the files are identical on every machine and the
# expired certificate stays expired. Nothing here touches a certificate store.
$labDir = Join-Path (Get-Location) 'certs'
$null = New-Item -ItemType Directory -Path $labDir -Force

function New-LabCertificate {
    param(
        [string]   $Subject,
        [datetime] $NotBefore,
        [datetime] $NotAfter,
        [int]      $Serial,
        [switch]   $IsCa,
        [string]   $DnsName,
        [X509Certificate2] $Issuer   # omit for a self-signed root
    )
    $key = [RSA]::Create(2048)
    $req = [CertificateRequest]::new($Subject, $key,
        [HashAlgorithmName]::SHA256, [RSASignaturePadding]::Pkcs1)

    # certificateAuthority=$true is what lets a certificate sign others. A leaf
    # with this set to $true is a misissued certificate, not a convenience.
    $req.CertificateExtensions.Add(
        [X509BasicConstraintsExtension]::new($IsCa, $false, 0, $true))

    if ($DnsName) {
        # DnsNameList is built from the SAN extension, so a leaf without one has
        # no name to match a host against.
        $san = [SubjectAlternativeNameBuilder]::new()
        $san.AddDnsName($DnsName)
        $req.CertificateExtensions.Add($san.Build())

        $eku = [OidCollection]::new()
        $null = $eku.Add([Oid]::new('1.3.6.1.5.5.7.3.1'))   # Server Authentication
        $req.CertificateExtensions.Add([X509EnhancedKeyUsageExtension]::new($eku, $false))
    }

    if ($Issuer) {
        $cert = $req.Create($Issuer, $NotBefore, $NotAfter, [byte[]]@(0, 0, 0, $Serial))
    } else {
        $cert = $req.CreateSelfSigned($NotBefore, $NotAfter)
    }
    # CreateSelfSigned() returns a certificate that already holds the key;
    # Create() returns one that does not. Re-attaching unconditionally throws
    # "The certificate already has an associated private key", so test first.
    # The static form is used because the instance method has overloads for
    # several key types and PowerShell binds the wrong one from an RSA object.
    if ($cert.HasPrivateKey) { $cert } else { [RSACertificateExtensions]::CopyWithPrivateKey($cert, $key) }
}

# Kind is set to Utc explicitly so the DateTimeOffset these become carries a
# zero offset whatever time zone the machine is in. The CA dates start well
# before the leaf dates because Create() refuses to issue a certificate whose
# notBefore is earlier than the issuer's.
$caStart = [datetime]::new(2020, 1, 1, 0, 0, 0, [DateTimeKind]::Utc)
$longAgo = [datetime]::new(2024, 1, 1, 0, 0, 0, [DateTimeKind]::Utc)
$dead    = [datetime]::new(2025, 1, 1, 0, 0, 0, [DateTimeKind]::Utc)
$start   = [datetime]::new(2026, 1, 1, 0, 0, 0, [DateTimeKind]::Utc)
$far     = [datetime]::new(2036, 1, 1, 0, 0, 0, [DateTimeKind]::Utc)

$labRoot   = New-LabCertificate -Subject 'CN=LAB Root CA'    -NotBefore $caStart -NotAfter $far -Serial 1 -IsCa
$labIssue  = New-LabCertificate -Subject 'CN=LAB Issuing CA' -NotBefore $caStart -NotAfter $far -Serial 2 -IsCa -Issuer $labRoot
$rogueRoot = New-LabCertificate -Subject 'CN=ROGUE Root CA'  -NotBefore $caStart -NotAfter $far -Serial 3 -IsCa

$webProd  = New-LabCertificate -Subject 'CN=web.lab.local'   -NotBefore $start   -NotAfter $far  -Serial 10 -DnsName 'web.lab.local'   -Issuer $labIssue
$webOld   = New-LabCertificate -Subject 'CN=old.lab.local'   -NotBefore $longAgo -NotAfter $dead -Serial 11 -DnsName 'old.lab.local'   -Issuer $labIssue
$webRogue = New-LabCertificate -Subject 'CN=rogue.lab.local' -NotBefore $start   -NotAfter $far  -Serial 12 -DnsName 'rogue.lab.local' -Issuer $rogueRoot

# Export the public part only. A .cer file holds no private key, which is why
# these are safe to copy around and to read with Get-FileHash.
$set = [ordered]@{
    'lab-root.cer'    = $labRoot
    'lab-issuing.cer' = $labIssue
    'rogue-root.cer'  = $rogueRoot
    'web-prod.cer'    = $webProd
    'web-old.cer'     = $webOld
    'web-rogue.cer'   = $webRogue
}
'{0,-18}{1,-22}{2}' -f 'FILE', 'SUBJECT', 'EXPIRES (UTC)'
foreach ($name in $set.Keys) {
    $path = Join-Path $labDir $name
    [System.IO.File]::WriteAllBytes($path, $set[$name].Export([X509ContentType]::Cert))
    $c = $set[$name]
    '{0,-18}{1,-22}{2}' -f $name, $c.Subject, $c.NotAfter.ToUniversalTime().ToString('yyyy-MM-dd')
}

# One PFX as well, because a TLS listener has to present a private key. The
# password is here only so the lab runs unattended; a real export belongs
# nowhere near a script file.
$pfx = Join-Path $labDir 'web-prod.pfx'
[System.IO.File]::WriteAllBytes($pfx, $webProd.Export([X509ContentType]::Pfx, 'labpass'))
'{0,-18}{1,-22}{2}' -f 'web-prod.pfx', $webProd.Subject, 'private key included'

It prints one line per file as it writes it. You should see exactly this:

FILE              SUBJECT               EXPIRES (UTC)
lab-root.cer      CN=LAB Root CA        2036-01-01
lab-issuing.cer   CN=LAB Issuing CA     2036-01-01
rogue-root.cer    CN=ROGUE Root CA      2036-01-01
web-prod.cer      CN=web.lab.local      2036-01-01
web-old.cer       CN=old.lab.local      2025-01-01
web-rogue.cer     CN=rogue.lab.local    2036-01-01
web-prod.pfx      CN=web.lab.local      private key included

Confirm the folder really has all seven files before you go on.

Save this as Show-LabFiles.ps1 in C:\pki-lab and run it with .\Show-LabFiles.ps1.

# -Name prints just the file names with no sizes or dates, which is all that is
# needed to confirm the set is complete.
Get-ChildItem -Path certs -Name

Seven names, in alphabetical order:

lab-issuing.cer
lab-root.cer
rogue-root.cer
web-old.cer
web-prod.cer
web-prod.pfx
web-rogue.cer
Note: Create() refuses to issue a certificate whose notBefore is earlier than the issuer’s, which is why the two CAs in that script start in 2020 while the expired leaf starts in 2024. That rule is enforced when the certificate is signed, not when it is checked.

Step 4. Save the helper function. Three later sections pull a certificate off a live TLS listener. Rather than repeat the code three times, save it once under this exact name. You do not run this one: the scripts that need it load it themselves with a leading dot, which is how PowerShell brings a function into the current session.

Save this as Get-TlsCertificate.ps1 in C:\pki-lab. There is no output, because nothing runs yet.

using namespace System.Net.Security
using namespace System.Net.Sockets
using namespace System.Security.Cryptography.X509Certificates

function Get-TlsCertificate {
    param([string] $TargetName, [string] $Address = '127.0.0.1', [int] $Port = 8443)

    $seen = [pscustomobject]@{ Errors = $null; Protocol = $null; Certificate = $null; SentByPeer = 0 }
    $tcp = [TcpClient]::new()
    try {
        $tcp.Connect($Address, $Port)
        $callback = {
            param($sender, $certificate, $chain, $sslPolicyErrors)
            $seen.Errors = $sslPolicyErrors
            $seen.SentByPeer = $chain.ChainElements.Count
            $true
        }.GetNewClosure()
        $ssl = [SslStream]::new($tcp.GetStream(), $false, $callback)
        try {
            $ssl.AuthenticateAsClient($TargetName)
            $seen.Protocol = $ssl.SslProtocol
            $seen.Certificate = [X509Certificate2]::new($ssl.RemoteCertificate)
        } finally { $ssl.Dispose() }
    } finally { $tcp.Dispose() }
    $seen
}
Common mistake: The callback in that function returns $true for every certificate, which is exactly what makes it possible to inspect a broken one. It validates nothing. Never reuse that callback in code that then sends or receives real data: the validation has to happen separately, which is what the chain sections do.
Result: The lab is ready. C:\pki-lab now holds a certs folder with seven files in it, plus the scripts you have saved so far. Every example from here on is one more script saved into that same folder and run the same way.

The three pieces that do the work

Before the examples, it is worth knowing which of three .NET types owns which question. Almost every script below is one of them.

TypeOwnsThe thing people get wrong
X509Certificate2One certificate: subject, issuer, dates, serial, extensions, thumbprintIts date properties are in local time, not UTC
X509ChainWhether a certificate chains up to something trusted, and why notBuild() is the verdict, ChainStatus is the reason
SslStreamThe handshake that gets a certificate off a live listenerA finished handshake is not a validated chain

The chain policy is where the interesting defaults live. These four change what an answer means, and the revocation section reads all four back from a fresh policy object rather than quoting them from memory.

PropertyDocumented defaultWhat it does
RevocationModeOnlineAsks a CRL or OCSP endpoint whether the certificate was revoked
RevocationFlagExcludeRootWhich certificates in the chain the revocation check covers
TrustModeSystemWhether the machine’s roots or your own list define trust
UrlRetrievalTimeout00:00:00How long revocation retrieval may take, where zero means no limit
Note: Two collections on the policy look alike and are not. CustomTrustStore is documented as a collection "replacing the default certificate trust", and it is respected only when TrustMode is set to CustomRootTrust. ExtraStore confers no trust at all: it is a pool of candidate issuers the engine may use to fill in the middle of a chain. Both appear in the scripts below, and mixing them up is the commonest way to get a confusing answer.

Example 1: read a certificate from a file

The problem: a colleague has emailed a .cer file and asked whether it is the right one. Opening it in the Windows certificate dialog answers that for one file and stops being an answer at all for a folder of them.

The solution: X509Certificate2 takes a path and gives back every field, with no store involvement and no elevation. The extension list at the end is worth printing, because the absence of a subject alternative name is a common reason a certificate that looks fine is rejected by a client, and it is one line to rule out.

Save this as Show-Certificate.ps1 in C:\pki-lab and run it with .\Show-Certificate.ps1.

using namespace System.Security.Cryptography.X509Certificates

# The constructor takes a path directly. No certificate store is involved, so
# this works the same for a file that was never imported anywhere.
$cert = [X509Certificate2]::new('certs/web-prod.cer')

'{0,-18}{1}' -f 'Subject',      $cert.Subject
'{0,-18}{1}' -f 'Issuer',       $cert.Issuer
'{0,-18}{1}' -f 'SerialNumber', $cert.SerialNumber
'{0,-18}{1}' -f 'HasPrivateKey', $cert.HasPrivateKey
'{0,-18}{1}' -f 'SignatureAlg', $cert.SignatureAlgorithm.FriendlyName
'{0,-18}{1}' -f 'Extensions',   $cert.Extensions.Count

# A .cer file holds the public certificate only, so every value above is
# readable without any private key and without elevation.
foreach ($ext in $cert.Extensions) {
    '  ext {0,-36}{1}' -f $ext.Oid.FriendlyName, $ext.Oid.Value
}

Six labelled values, then one line per extension. You should see this:

Subject           CN=web.lab.local
Issuer            CN=LAB Issuing CA
SerialNumber      0A
HasPrivateKey     False
SignatureAlg      sha256RSA
Extensions        3
  ext X509v3 Basic Constraints            2.5.29.19
  ext X509v3 Subject Alternative Name     2.5.29.17
  ext X509v3 Extended Key Usage           2.5.29.37

Read that output from the top. The subject is the name the certificate is for, the issuer is the CA that signed it, and the two names differ, which is what you want: a certificate whose subject and issuer are identical is self-signed. Three extensions is the healthy count for a server certificate built this way, and the middle one, Subject Alternative Name, is the one a TLS client matches a host name against.

Note: SerialNumber reads 0A rather than 0000000A because it is documented as a big-endian hexadecimal string and the leading zero bytes carry no value. Compare serial numbers as strings and the two spellings will not match, which is one way a certificate looks absent from a CRL that does list it.
Warning: HasPrivateKey reads False above, and that is the expected result for a .cer file: it holds the public certificate only. If you are checking a certificate because a service will not start, HasPrivateKey reading False on the copy in the store is the first thing to rule out.

Example 2: the expiry date PowerShell shows is in local time

The problem: a monitoring script reports a certificate as having a couple of hours more life than the certificate authority thinks it has, or a couple of hours less. Nobody notices until something expires inside the gap.

The solution: know which clock the property is on. NotAfter is documented as "the date in local time after which a certificate is no longer valid", and NotBefore is documented the same way. The instant stored in the certificate is in UTC; the property converts it on the way out. So the value is right, and it is not the value openssl prints for the same file.

Save this as Show-ExpiryClocks.ps1 in C:\pki-lab and run it with .\Show-ExpiryClocks.ps1.

using namespace System.Security.Cryptography.X509Certificates

# NotAfter is documented as "the date in local time after which a certificate is
# no longer valid". The instant inside the certificate is UTC; the property
# converts it to the machine's zone before handing it over.
$cert = [X509Certificate2]::new('certs/web-old.cer')

# A fixed wall-clock reference, shaped like what Get-Date hands back.
$asOf = [datetime]'2026-10-02 12:00:00'

'{0,-36}{1}' -f 'TimeZoneInfo::Local',          [TimeZoneInfo]::Local.Id
'{0,-36}{1}' -f 'NotAfter.Kind',                $cert.NotAfter.Kind
'{0,-36}{1}' -f 'NotAfter',                     $cert.NotAfter.ToString('yyyy-MM-dd HH:mm:ss')
'{0,-36}{1}' -f 'NotAfter.ToUniversalTime()',   $cert.NotAfter.ToUniversalTime().ToString('yyyy-MM-dd HH:mm:ss')
'{0,-36}{1}' -f 'Offset at the expiry instant', [TimeZoneInfo]::Local.GetUtcOffset($cert.NotAfter)
'{0,-36}{1}' -f 'Offset at the reference date', [TimeZoneInfo]::Local.GetUtcOffset($asOf)

# DateTime subtraction ignores Kind, so subtracting a UTC instant from a Local
# value is wrong by the local offset with no error and no warning.
$wrong = $cert.NotAfter - $asOf.ToUniversalTime()
$right = $cert.NotAfter - $asOf
'{0,-36}{1}' -f 'Remaining, mixing UTC and local', $wrong
'{0,-36}{1}' -f 'Remaining, staying in local',     $right
'{0,-36}{1}' -f 'Size of the error',               ($wrong - $right)
Note: The output below was captured on a machine whose time zone is set to Europe/Rome. Your first line will show your own time zone and your numbers will differ, which is the whole point of the example. On a machine running UTC every line reads the same wall-clock time and the error is zero, which is exactly why this is easy to miss in a test environment and not in production.

Nine labelled values. On that machine:

TimeZoneInfo::Local                 Europe/Rome
NotAfter.Kind                       Local
NotAfter                            2025-01-01 01:00:00
NotAfter.ToUniversalTime()          2025-01-01 00:00:00
Offset at the expiry instant        01:00:00
Offset at the reference date        02:00:00
Remaining, mixing UTC and local     -639.09:00:00
Remaining, staying in local         -639.11:00:00
Size of the error                   02:00:00

Three of those lines matter. NotAfter.Kind is Local, so the value carries a zone. NotAfter reads 2025-01-01 01:00:00 where openssl reads notAfter=Jan 1 00:00:00 2025 GMT for the same file, and both are correct. And the size of the error on the last line is two hours, not the one hour of offset in force at the expiry instant.

That last point is the part worth slowing down for. DateTime subtraction ignores Kind entirely: it subtracts the wall-clock numbers and hands back the difference. So the error is the offset in force at the moment of the comparison, which in the output above is summer time, while the offset at the certificate’s own expiry instant is winter time. The two offsets are printed on separate lines so you can compare them directly.

Common mistake: Reaching for .ToUniversalTime() to "normalise" the comparison is what causes this. It converts one side and not the other, and no error is raised. Either keep both sides local, as $cert.NotAfter - (Get-Date) does, or convert both, as $cert.NotAfter.ToUniversalTime() - (Get-Date).ToUniversalTime() does. Converting one is the only broken option and it is the one that looks careful.

Example 3: days remaining, and the guard that lets an expired certificate through

The problem: the renewal report has run every morning for a year and a certificate expired anyway, without appearing on it once.

The solution: look at the condition, not the arithmetic. The common guard asks whether a certificate expires within thirty days and is still valid, and the second half of that is what hides an expired certificate: once the number goes negative, the certificate drops out of the report it should be at the top of. The script below runs that guard against the healthy certificate and the expired one.

Save this as Show-DaysLeft.ps1 in C:\pki-lab and run it with .\Show-DaysLeft.ps1.

using namespace System.Security.Cryptography.X509Certificates

# $asOf is a fixed reference date so this example prints the same numbers on any
# day. Replace it with (Get-Date) to report against now.
$asOf = [datetime]'2026-10-02 00:00:00'

'{0,-16}{1,-14}{2,10}  {3}' -f 'FILE', 'EXPIRES', 'DAYS LEFT', 'VERDICT -le 30 -and -gt 0'
foreach ($file in 'web-prod.cer', 'web-old.cer') {
    $cert = [X509Certificate2]::new("certs/$file")
    $days = [int]($cert.NotAfter - $asOf).TotalDays
    # The guard below is the common one, and it is wrong: an already-expired
    # certificate gives a negative number and fails the -gt 0 half silently.
    $verdict = if ($days -le 30 -and $days -gt 0) { 'renew' } else { 'no action' }
    '{0,-16}{1,-14}{2,10}  {3}' -f $file, $cert.NotAfter.ToString('yyyy-MM-dd'), $days, $verdict
}

A header and two rows. You should see this, on any day and in any time zone, because the reference date in the script is fixed:

FILE            EXPIRES        DAYS LEFT  VERDICT -le 30 -and -gt 0
web-prod.cer    2036-01-01          3378  no action
web-old.cer     2025-01-01          -639  no action
Common mistake: Look at the last column. Both rows read no action, and one of those certificates expired 639 days before the reference date. A certificate that is dead is not "not yet due for renewal", and a guard written as -le 30 -and -gt 0 cannot say so. Test for expired first, then for expiring, which is the order the report at the end of this page uses.
Note: $asOf is a fixed date so the numbers stay the same whenever you run it. Change that one line to $asOf = Get-Date and it reports against today instead; the two verdicts do not change, which is the point.

Example 4: the thumbprint is not a hash of the certificate file

The problem: a binding, a GPO or an IIS configuration wants a thumbprint, and running a file hashing tool over the .cer file produces something that does not match it.

The solution: find out what the two values actually are. GetCertHashString() is documented as returning "the SHA-1 hash value for the X.509v3 certificate as a hexadecimal string". The Thumbprint property is documented only as "the thumbprint of a certificate", with no algorithm named, so the script measures the relationship rather than assuming it.

Save this as Compare-Thumbprint.ps1 in C:\pki-lab and run it with .\Compare-Thumbprint.ps1.

using namespace System.Security.Cryptography
using namespace System.Security.Cryptography.X509Certificates

# GetCertHashString() is documented as "the SHA-1 hash value for the X.509v3
# certificate". Thumbprint is documented only as "the thumbprint of a
# certificate", so the equality below is measured here rather than quoted.
$path = 'certs/web-prod.cer'
$cert = [X509Certificate2]::new($path)

$fileBytes = [System.IO.File]::ReadAllBytes($path)
$sha1File  = [BitConverter]::ToString([SHA1]::HashData($fileBytes)).Replace('-', '')
$sha256File = [BitConverter]::ToString([SHA256]::HashData($fileBytes)).Replace('-', '')

'{0,-44}{1}' -f 'Thumbprint equals GetCertHashString()', ($cert.Thumbprint -eq $cert.GetCertHashString())
'{0,-44}{1}' -f 'Thumbprint equals SHA-1 of the file',   ($cert.Thumbprint -eq $sha1File)
'{0,-44}{1}' -f 'Thumbprint equals SHA-256 of the file', ($cert.Thumbprint -eq $sha256File)
'{0,-44}{1}' -f 'Thumbprint hex characters',             $cert.Thumbprint.Length
'{0,-44}{1}' -f 'SHA-256 of the file, hex characters',   $sha256File.Length

# GetCertHash() takes an algorithm name, so a SHA-256 thumbprint is available
# from the certificate itself. It is not what the Thumbprint property returns.
$sha256Cert = [BitConverter]::ToString($cert.GetCertHash([HashAlgorithmName]::SHA256)).Replace('-', '')
'{0,-44}{1}' -f 'GetCertHash(SHA256) matches file SHA-256', ($sha256Cert -eq $sha256File)
Note: The script prints True and False rather than the digests themselves. That is deliberate: your lab certificates have different keys from the ones these outputs came from, so your digests will differ and the answers will not. Every line below should match on your machine exactly.

Six labelled answers:

Thumbprint equals GetCertHashString()       True
Thumbprint equals SHA-1 of the file         True
Thumbprint equals SHA-256 of the file       False
Thumbprint hex characters                   40
SHA-256 of the file, hex characters         64
GetCertHash(SHA256) matches file SHA-256    True

So for this file the thumbprint equals GetCertHashString() and equals the SHA-1 of the file’s bytes, and it does not equal the SHA-256 of them. It is forty hex characters where a SHA-256 digest is sixty-four, which is enough to tell at a glance which of the two you are holding.

Note: A .cer file in DER form is the encoded certificate and nothing else, which is why a hash of the file and a hash of the certificate agree here. They would not agree for a PEM file, where the bytes on disk are base64 text with header lines around it, nor for a PFX, which is a different container entirely. The hashing traps that go with that are the subject of the Get-FileHash and certutil comparison guide.
Result: GetCertHash() takes an algorithm name, so a SHA-256 thumbprint is available from the certificate object directly and matches a SHA-256 of the DER file. If a vendor asks for a "SHA-256 fingerprint", that is the call to make, and the Thumbprint property is not it.

Example 5: validate against a root you name

The problem: the same validation script passes on a workstation and fails on a build agent, because the two machines trust different roots. Nothing in the script changed, so nothing in the script explains it.

The solution: stop asking the machine. TrustMode set to CustomRootTrust makes CustomTrustStore the definition of trust for that one chain build, and the documentation is explicit that the collection "replaces the default certificate trust" rather than adding to it. The result then depends on the certificates you hand in and nothing else, which is why your output will match this one even though your lab root is not trusted by your machine.

Save this as Test-GoodChain.ps1 in C:\pki-lab and run it with .\Test-GoodChain.ps1.

using namespace System.Security.Cryptography.X509Certificates

$leaf  = [X509Certificate2]::new('certs/web-prod.cer')
$root  = [X509Certificate2]::new('certs/lab-root.cer')
$inter = [X509Certificate2]::new('certs/lab-issuing.cer')

$chain = [X509Chain]::new()

# CustomTrustStore is respected only when TrustMode is CustomRootTrust, and it
# replaces the machine's root trust rather than adding to it. That is what makes
# this check give the same answer on a workstation and on a build agent.
$chain.ChainPolicy.TrustMode = [X509ChainTrustMode]::CustomRootTrust
$null = $chain.ChainPolicy.CustomTrustStore.Add($root)

# ExtraStore is where the chaining engine looks for intermediates. It confers no
# trust: a certificate placed here is a candidate issuer, nothing more.
$null = $chain.ChainPolicy.ExtraStore.Add($inter)

# RevocationMode defaults to Online. The lab CA publishes no CRL, so leaving the
# default on would measure the network rather than the chain.
$chain.ChainPolicy.RevocationMode = [X509RevocationMode]::NoCheck

$ok = $chain.Build($leaf)
'{0,-26}{1}' -f 'Build()', $ok
'{0,-26}{1}' -f 'ChainStatus entries', $chain.ChainStatus.Count
'{0,-26}{1}' -f 'ChainElements', $chain.ChainElements.Count
foreach ($s in $chain.ChainStatus) { '  status {0}' -f $s.Status }

Three labelled values, and no status lines after them:

Build()                   True
ChainStatus entries       0
ChainElements             3
Result: Build() returns True, three elements were found (the leaf, the intermediate and the root), and ChainStatus holds zero entries rather than one entry reading NoError. A clean chain is an empty status array. Any code that walks ChainStatus looking for NoError to confirm success will never find it.
Warning: RevocationMode is set to NoCheck in that script on purpose, and the comment in it says why: the lab CA publishes no revocation endpoint, so leaving the documented Online default in place would measure the network rather than the chain. Example 8 shows exactly what happens if you leave it. On a real internal CA that publishes a CRL, leave revocation on.

Example 6: False does not mean expired

The problem: a check says the certificate is not valid. The certificate has nine years left on it. Both statements are true and the ticket goes to the wrong team.

The solution: read ChainStatus, because that is where the reason lives. This is the centre of the page: one helper is run against five combinations of leaf, intermediate and root, nothing else changes between rows, and each row prints what came back.

Save this as Test-ChainCases.ps1 in C:\pki-lab and run it with .\Test-ChainCases.ps1.

using namespace System.Security.Cryptography.X509Certificates

# One helper, used for every case below, so the only thing that varies between
# rows is which certificates the caller supplies.
function Test-LabChain {
    param(
        [string]   $Leaf,
        [string[]] $Intermediates = @(),
        [string[]] $Roots = @()
    )
    $chain = [X509Chain]::new()
    $chain.ChainPolicy.TrustMode = [X509ChainTrustMode]::CustomRootTrust
    $chain.ChainPolicy.RevocationMode = [X509RevocationMode]::NoCheck
    foreach ($r in $Roots)         { $null = $chain.ChainPolicy.CustomTrustStore.Add([X509Certificate2]::new($r)) }
    foreach ($i in $Intermediates) { $null = $chain.ChainPolicy.ExtraStore.Add([X509Certificate2]::new($i)) }

    $ok = $chain.Build([X509Certificate2]::new($Leaf))
    # ChainStatus aggregates every element's status. An empty array alongside a
    # $false would be the surprising case, so report the count as well.
    $flags = if ($chain.ChainStatus.Count) { ($chain.ChainStatus.Status -join ',') } else { '(none)' }
    [pscustomobject]@{
        Build    = $ok
        Elements = $chain.ChainElements.Count
        Flags    = $flags
    }
}

$root  = 'certs/lab-root.cer'
$inter = 'certs/lab-issuing.cer'
$rogue = 'certs/rogue-root.cer'

$cases = [ordered]@{
    'healthy'              = @{ Leaf = 'certs/web-prod.cer';  Intermediates = @($inter); Roots = @($root) }
    'expired leaf'         = @{ Leaf = 'certs/web-old.cer';   Intermediates = @($inter); Roots = @($root) }
    'intermediate missing' = @{ Leaf = 'certs/web-prod.cer';  Intermediates = @();       Roots = @($root) }
    'root not trusted'     = @{ Leaf = 'certs/web-rogue.cer'; Intermediates = @($rogue); Roots = @($root) }
    'nothing supplied'     = @{ Leaf = 'certs/web-prod.cer';  Intermediates = @();       Roots = @() }
}

'{0,-22}{1,-7}{2,9}  {3}' -f 'CASE', 'BUILD', 'ELEMENTS', 'CHAINSTATUS FLAGS'
foreach ($name in $cases.Keys) {
    $caseArgs = $cases[$name]
    $r = Test-LabChain @caseArgs
    '{0,-22}{1,-7}{2,9}  {3}' -f $name, $r.Build, $r.Elements, $r.Flags
}

A header and five rows. You should see this exactly:

CASE                  BUILD   ELEMENTS  CHAINSTATUS FLAGS
healthy               True           3  (none)
expired leaf          False          3  NotTimeValid
intermediate missing  False          1  PartialChain
root not trusted      False          2  UntrustedRoot
nothing supplied      False          1  PartialChain

Read it as five experiments. The first row is the healthy case from example 5. The second uses the expired leaf and nothing else changes. The third withholds the intermediate. The fourth uses a certificate signed by the rogue root while still trusting only the lab root. The fifth hands the checker nothing at all.

One success and four failures, and those four carry three distinct flags between them. The element count is the second half of the diagnosis: a chain that stopped at one element never found an issuer for the leaf at all, while a chain that got further found its issuers and failed for some other reason. That gives you a lookup table for real certificates.

What the output saidWhat is wrongWhere to fix it
NotTimeValid, 3 elementsThe chain is complete and something on it is out of dateRenew, then use example 7 to find which element carried the flag
PartialChain, 1 elementNo issuer for the leaf was available anywhereSupply the intermediate, or install it on the server that serves the leaf
UntrustedRoot, 2 elementsThe chain reached a root and that root is not trustedTrust the root deliberately, or reissue from a CA that is trusted
(none), 3 elementsNothing is wrongNowhere

Those three flags are the ones the five cases produced, and they are three of a much longer list. X509ChainStatusFlags is the entire vocabulary Build() has for saying no, so counting it puts a number on how little a boolean tells you.

Save this as Show-StatusFlags.ps1 in C:\pki-lab and run it with .\Show-StatusFlags.ps1.

using namespace System.Security.Cryptography.X509Certificates

# X509ChainStatusFlags is the whole vocabulary Build() has for saying no. One of
# its members means success, so the rest are the ways a chain check can fail.
$names = [enum]::GetNames([X509ChainStatusFlags])
'{0,-34}{1}' -f 'Members in the enumeration', $names.Count
'{0,-34}{1}' -f 'Members other than NoError', ($names | Where-Object { $_ -ne 'NoError' }).Count

# The flags are a bit field, so one element can carry several at once.
$combo = [X509ChainStatusFlags]::NotTimeValid -bor [X509ChainStatusFlags]::UntrustedRoot
'{0,-34}{1}' -f 'NotTimeValid value', [int][X509ChainStatusFlags]::NotTimeValid
'{0,-34}{1}' -f 'UntrustedRoot value', [int][X509ChainStatusFlags]::UntrustedRoot
'{0,-34}{1}' -f 'Both together, as a number', [int]$combo
'{0,-34}{1}' -f 'Both together, as names', $combo
'{0,-34}{1}' -f 'HasFlag(NotTimeValid)', $combo.HasFlag([X509ChainStatusFlags]::NotTimeValid)

Seven labelled values:

Members in the enumeration        26
Members other than NoError        25
NotTimeValid value                1
UntrustedRoot value               32
Both together, as a number        33
Both together, as names           NotTimeValid, UntrustedRoot
HasFlag(NotTimeValid)             True

Twenty-six members, one of which is NoError, leaving twenty-five ways to fail. One of the twenty-five, NotTimeNested, is marked deprecated in the documentation. The last three lines are the part that bites: the flags are a bit field, so one element can report several at once. The two in that block have the values 1 and 32 and combine to 33, which is why HasFlag() is the right way to test for one of them and -eq is not.

Common mistake: Comparing $status.Status with -eq against a single flag works right up to the first certificate that is both expired and issued by an untrusted root, because that certificate carries two flags at once and equals neither of them on its own. Use HasFlag().

Example 7: which certificate in the chain is at fault

The problem: the chain says NotTimeValid and there are three certificates in it. Renewing the wrong one costs a change window.

The solution: ChainElements is documented as ordered "from the end-entity (leaf) certificate at index 0, through any intermediate certificates, to the trust anchor (root certificate) at the final index", and the documentation states that the ordering is consistent across all platforms. Each element carries its own ChainElementStatus, so the flag can be attributed to a subject rather than to the chain as a whole. The script builds the expired case from example 6 and prints the chain a row at a time.

Save this as Show-ChainElements.ps1 in C:\pki-lab and run it with .\Show-ChainElements.ps1.

using namespace System.Security.Cryptography.X509Certificates

# ChainElements is documented as ordered "from the end-entity (leaf) certificate
# at index 0, through any intermediate certificates, to the trust anchor (root
# certificate) at the final index", and that ordering is the same on every
# platform. It is what turns a failed check into a named culprit.
$chain = [X509Chain]::new()
$chain.ChainPolicy.TrustMode = [X509ChainTrustMode]::CustomRootTrust
$chain.ChainPolicy.RevocationMode = [X509RevocationMode]::NoCheck
$null = $chain.ChainPolicy.CustomTrustStore.Add([X509Certificate2]::new('certs/lab-root.cer'))
$null = $chain.ChainPolicy.ExtraStore.Add([X509Certificate2]::new('certs/lab-issuing.cer'))

$null = $chain.Build([X509Certificate2]::new('certs/web-old.cer'))

'{0,-7}{1,-22}{2,-22}{3}' -f 'INDEX', 'SUBJECT', 'ISSUER', 'ELEMENT STATUS'
for ($i = 0; $i -lt $chain.ChainElements.Count; $i++) {
    $el = $chain.ChainElements[$i]
    $st = if ($el.ChainElementStatus.Count) { ($el.ChainElementStatus.Status -join ',') } else { '(none)' }
    '{0,-7}{1,-22}{2,-22}{3}' -f $i, $el.Certificate.Subject, $el.Certificate.Issuer, $st
}

A header and one row per certificate in the chain:

INDEX  SUBJECT               ISSUER                ELEMENT STATUS
0      CN=old.lab.local      CN=LAB Issuing CA     NotTimeValid
1      CN=LAB Issuing CA     CN=LAB Root CA        (none)
2      CN=LAB Root CA        CN=LAB Root CA        (none)
Result: The flag sits on index 0 and the other two rows are clean, so the leaf is the thing to renew and the CAs are fine. The root is recognisable without reading the index at all: it is the row whose subject and issuer are the same string.

Example 8: revocation runs online by default

The problem: a validation script that worked on the office network hangs, or fails every certificate, when it runs somewhere with no outbound access.

The solution: know the defaults before you inherit them. The script reads all four back from a fresh policy object rather than taking anyone’s word for them, then builds the healthy chain twice: once with revocation off and once with the documented default left in place.

Save this as Test-Revocation.ps1 in C:\pki-lab and run it with .\Test-Revocation.ps1.

using namespace System.Security.Cryptography.X509Certificates

# RevocationMode is documented with Online as the default value and
# RevocationFlag with ExcludeRoot, so a chain built straight out of the
# constructor reaches for the network before it answers.
$policy = [X509ChainPolicy]::new()
'{0,-30}{1}' -f 'Default RevocationMode', $policy.RevocationMode
'{0,-30}{1}' -f 'Default RevocationFlag', $policy.RevocationFlag
'{0,-30}{1}' -f 'Default TrustMode',      $policy.TrustMode
'{0,-30}{1}' -f 'Default UrlRetrievalTimeout', $policy.UrlRetrievalTimeout

# The lab CA publishes no CRL distribution point and no OCSP responder, so
# leaving the default in place asks a question that cannot be answered.
foreach ($mode in 'NoCheck', 'Online') {
    $chain = [X509Chain]::new()
    $chain.ChainPolicy.TrustMode = [X509ChainTrustMode]::CustomRootTrust
    $chain.ChainPolicy.RevocationMode = [X509RevocationMode]::$mode
    $null = $chain.ChainPolicy.CustomTrustStore.Add([X509Certificate2]::new('certs/lab-root.cer'))
    $null = $chain.ChainPolicy.ExtraStore.Add([X509Certificate2]::new('certs/lab-issuing.cer'))
    $ok = $chain.Build([X509Certificate2]::new('certs/web-prod.cer'))
    $flags = if ($chain.ChainStatus.Count) { ($chain.ChainStatus.Status -join ',') } else { '(none)' }
    '{0,-30}{1,-7}{2}' -f "RevocationMode $mode", $ok, $flags
}

Four defaults, then two result rows:

Default RevocationMode        Online
Default RevocationFlag        ExcludeRoot
Default TrustMode             System
Default UrlRetrievalTimeout   00:00:00
RevocationMode NoCheck        True   (none)
RevocationMode Online         False  RevocationStatusUnknown,OfflineRevocation

The lab CA publishes no CRL distribution point and no OCSP responder, so the default asks a question that cannot be answered, and the healthy chain from example 5 comes back False with RevocationStatusUnknown and OfflineRevocation. The certificate did not change. The question did.

Warning: UrlRetrievalTimeout defaults to 00:00:00, and the documentation states that a value of zero "means there are no limits". A revocation check against an endpoint that accepts the connection and then goes quiet has no deadline of its own, so set this property explicitly in anything that runs unattended.
Note: The two flags on the last row are what this machine produced, running PowerShell 7.4.6 on .NET 8. What a certificate with no revocation endpoint yields can differ between platforms, so do not treat that exact pair as a contract. The four documented defaults above it are the portable part, and they are the part that decides whether your check depends on the network.

Example 9: take the certificate off a live listener

The problem: the certificate you need to inspect is not on disk. It is being served by something, and that something is the only copy you have access to.

The solution: do the handshake yourself. This example needs two PowerShell windows, because one of them has to keep running.

In the first window, save the listener and start it. It presents the lab certificate on 127.0.0.1:8443 and then waits, so this window is busy until you stop it.

Save this as Start-LabListener.ps1 in C:\pki-lab and run it with .\Start-LabListener.ps1.

using namespace System.Net
using namespace System.Net.Security
using namespace System.Net.Sockets
using namespace System.Security.Cryptography.X509Certificates

# A throwaway TLS listener presenting the lab leaf certificate, so the client
# script below has something real to shake hands with. Loopback only.
$cert = [X509Certificate2]::new('certs/web-prod.pfx', 'labpass')
$listener = [TcpListener]::new([IPAddress]::Loopback, 8443)
$listener.Start()
'listening on 127.0.0.1:8443 as {0}' -f $cert.Subject

while ($true) {
    $client = $listener.AcceptTcpClient()
    $ssl = [SslStream]::new($client.GetStream(), $false)
    # clientCertificateRequired $false, checkCertificateRevocation $false
    try { $ssl.AuthenticateAsServer($cert, $false, $false) } catch { }
    $ssl.Dispose()
    $client.Dispose()
}

One line, and then nothing. That is correct: it is waiting for a connection. Leave the window alone.

listening on 127.0.0.1:8443 as CN=web.lab.local

Open a second PowerShell window, run Set-Location C:\pki-lab in it as well, and run the client there. It connects twice: once with the name the certificate actually carries, and once with a name it does not.

Save this as Get-ListenerCertificate.ps1 in C:\pki-lab and run it with .\Get-ListenerCertificate.ps1.

# The helper is defined once, in Get-TlsCertificate.ps1, and dot-sourced wherever
# a certificate has to come off a live listener.
. ./Get-TlsCertificate.ps1

'{0,-22}{1,-10}{2,-14}{3}' -f 'TARGET NAME', 'PROTOCOL', 'SENT BY PEER', 'SSLPOLICYERRORS'
foreach ($name in 'web.lab.local', 'wrong.lab.local') {
    $r = Get-TlsCertificate -TargetName $name
    '{0,-22}{1,-10}{2,-14}{3}' -f $name, $r.Protocol, $r.SentByPeer, $r.Errors
}

$r = Get-TlsCertificate -TargetName 'web.lab.local'
$cert = $r.Certificate
'{0,-22}{1}' -f 'Subject',   $cert.Subject
'{0,-22}{1}' -f 'Issuer',    $cert.Issuer
'{0,-22}{1}' -f 'NotAfter (UTC)', $cert.NotAfter.ToUniversalTime().ToString('yyyy-MM-dd')
'{0,-22}{1}' -f 'DnsName in SAN', ($cert.Extensions |
    Where-Object { $_.Oid.Value -eq '2.5.29.17' }).Format($false)

A two-row table, then four labelled values:

TARGET NAME           PROTOCOL  SENT BY PEER  SSLPOLICYERRORS
web.lab.local         Tls13     1             RemoteCertificateChainErrors
wrong.lab.local       Tls13     1             RemoteCertificateNameMismatch, RemoteCertificateChainErrors
Subject               CN=web.lab.local
Issuer                CN=LAB Issuing CA
NotAfter (UTC)        2036-01-01
DnsName in SAN        DNS:web.lab.local

The two rows differ by exactly one flag. The host name a certificate gets matched against is the one passed to AuthenticateAsClient, not the address the socket connected to: both calls above went to 127.0.0.1 and only the second one reported RemoteCertificateNameMismatch. That is what makes it possible to test a certificate for a name before DNS points at the server, and it is also how a careless script can validate the wrong name without noticing.

Common mistake: The callback is handed the errors .NET already found, and returning $true throws that verdict away. The helper captures $sslPolicyErrors on the way past for exactly this reason. A callback that returns $true and keeps nothing is a connection that trusts anything, which is fine for inspection and is not fine for anything else.
Note: RemoteCertificateChainErrors appears on both rows because the lab root is not in this machine’s trust store, and it never should be. The next example decides about that chain explicitly instead.

Example 10: a finished handshake is not a valid chain

The problem: a site loads in a browser and a PowerShell script calling the same URL reports a certificate problem. Before concluding that the script is being difficult, find out what the server actually sent.

The solution: separate the two questions. The handshake asks whether the peer can prove it holds the key. The chain asks whether anything vouches for the peer. The SENT BY PEER column in the previous output is the clue: it reads 1, so the listener sent one certificate, its own, and no intermediate.

Keep the listener running in the first window. In the second window, run this. It fetches the certificate once and then validates it twice, changing only what the checker was given.

Save this as Test-ListenerChain.ps1 in C:\pki-lab and run it with .\Test-ListenerChain.ps1.

using namespace System.Security.Cryptography.X509Certificates

# Pull the certificate off the listener, then decide about it separately. The
# handshake and the verdict are two different questions.
. ./Get-TlsCertificate.ps1

$cert = (Get-TlsCertificate -TargetName 'web.lab.local').Certificate
$root  = [X509Certificate2]::new('certs/lab-root.cer')
$inter = [X509Certificate2]::new('certs/lab-issuing.cer')

function Test-Against {
    param([string] $Label, [X509Certificate2] $Leaf, [X509Certificate2[]] $Extra = @())
    $chain = [X509Chain]::new()
    $chain.ChainPolicy.TrustMode = [X509ChainTrustMode]::CustomRootTrust
    $chain.ChainPolicy.RevocationMode = [X509RevocationMode]::NoCheck
    $null = $chain.ChainPolicy.CustomTrustStore.Add($root)
    foreach ($e in $Extra) { $null = $chain.ChainPolicy.ExtraStore.Add($e) }
    $ok = $chain.Build($Leaf)
    $flags = if ($chain.ChainStatus.Count) { ($chain.ChainStatus.Status -join ',') } else { '(none)' }
    '{0,-34}{1,-7}{2,9}  {3}' -f $Label, $ok, $chain.ChainElements.Count, $flags
}

'{0,-34}{1,-7}{2,9}  {3}' -f 'WHAT THE CHECKER WAS GIVEN', 'BUILD', 'ELEMENTS', 'CHAINSTATUS FLAGS'
Test-Against -Label 'root only'             -Leaf $cert
Test-Against -Label 'root and intermediate' -Leaf $cert -Extra @($inter)

A header and two rows:

WHAT THE CHECKER WAS GIVEN        BUILD   ELEMENTS  CHAINSTATUS FLAGS
root only                         False          1  PartialChain
root and intermediate             True           3  (none)
Result: Same certificate, same listener, two different verdicts. Given the root alone the chain stops at one element with PartialChain. Given the root and the intermediate it completes at three elements with no flags. A server that does not send its intermediate is relying on every client to find it some other way, and PartialChain against a server that works elsewhere is the signature of exactly that.
Note: The fix belongs on the server: add the intermediate to the chain it presents. Adding the intermediate to every client that complains is the same work repeated once per client, and it hides the problem from the next one. The certificate and CSR decoder shows the chain a host sends, which is the quickest way to confirm the gap before changing anything.

When you are finished with examples 9 and 10, go back to the first window and press Ctrl and C together to stop the listener. Nothing else on this page needs it.


The Windows-only parts, and what their pages leave unsaid

Two things on Windows do some of this work for you, and both are worth knowing about and reading carefully. No output is shown for either, and neither is part of the lab, because neither runs on the machine these blocks were measured on and publishing invented output would defeat the purpose of the rest of the page. Treat these two blocks as reference rather than as steps to follow.

Test-Certificate is a cmdlet in the pki module. It verifies a certificate and returns, as its documented output type, a System.Boolean. It accepts a policy, a DNS name and a list of enhanced key usage identifiers.

# Verify every certificate in the machine's personal store for SSL use.
Get-ChildItem -Path Cert:\LocalMachine\My |
    Test-Certificate -Policy SSL -DNSName 'dns=contoso.com'

# Allow an untrusted root but still check everything else, such as expiry.
# Substitute a real forty-character thumbprint for THUMBPRINT.
Test-Certificate -Cert 'Cert:\CurrentUser\My\THUMBPRINT' -AllowUntrustedRoot
Warning: Its page states the revocation default twice and the two statements do not agree. The description says "The revocation status of the certificate is verified by default." The -AllowUntrustedRoot parameter says "If this parameter is not specified, then revocation status is checked by default." The second makes the revocation default conditional on a switch that is about root trust, and the first does not. Neither example on the page shows any output, so the page cannot settle it either.

A boolean is also the whole return value, which is the limitation this page opened with. Where you need the reason rather than the verdict, X509Chain is the thing that has one.

The Cert: drive has filtering parameters that Get-ChildItem only grows when it is pointed at a certificate store. -ExpiringInDays is documented as getting "certificates that are expiring in or before the specified number of days", with a value of zero getting "certificates that have expired".

# Certificates in the web hosting store with 30 days or less to run.
Get-ChildItem -Path Cert:\LocalMachine\WebHosting -ExpiringInDays 30

# A value of 0 is documented as returning the ones that have already expired.
Get-ChildItem -Path Cert:\LocalMachine\WebHosting -ExpiringInDays 0

# Server certificates only, by enhanced key usage.
Get-ChildItem -Path Cert:\LocalMachine\My -SSLServerAuthentication
Common mistake: That last filter widens as well as narrows. The provider documentation states that "Parameters that perform filtering against the EnhancedKeyUsageList property also return items with an empty EnhancedKeyUsageList property value", because "Certificates that have an empty EnhancedKeyUsageList can be used for all purposes". A filter for server certificates therefore also returns every certificate with no usage restriction at all.
Note: The provider adds a DnsNameList property built from the subject alternative name extension, and the documentation states that "If the SAN extension is empty, the property is populated with content from the Subject field of the certificate". So a non-empty DnsNameList is not evidence that the certificate has a subject alternative name, and TLS clients match against the extension.

Example 11: a folder-wide expiry report

The problem: there is a folder of certificate files on a jump box and nobody knows which of them still matter.

The solution: one pass that reads every file and sorts it into expired, due for renewal, or fine. This is the script worth keeping after the lab is gone: the same loop works over Get-ChildItem Cert:\LocalMachine\My on Windows, where each item is already an X509Certificate2 and the file read is not needed.

Save this as New-ExpiryReport.ps1 in C:\pki-lab and run it with .\New-ExpiryReport.ps1.

using namespace System.Security.Cryptography.X509Certificates

# One pass over a folder of .cer files. The same loop works over the output of
# Get-ChildItem Cert:\LocalMachine\My on Windows, where each item is already an
# X509Certificate2 and the file read is not needed.
$asOf = [datetime]'2026-10-02 00:00:00'

$report = foreach ($file in Get-ChildItem -Path certs -Filter *.cer | Sort-Object Name) {
    $cert = [X509Certificate2]::new($file.FullName)
    $days = [int]($cert.NotAfter - $asOf).TotalDays
    # Order matters: expired has to be tested before expiring, or a negative
    # number falls through to the renew bucket and reads as merely urgent.
    $state = if ($days -lt 0) { 'EXPIRED' } elseif ($days -le 30) { 'RENEW' } else { 'ok' }
    [pscustomobject]@{
        FileName = $file.Name
        Subject  = $cert.Subject
        Expires  = $cert.NotAfter.ToUniversalTime().ToString('yyyy-MM-dd')
        DaysLeft = $days
        State    = $state
    }
}

# Format-Table can print nothing at all under a redirected stream, so the rows
# are emitted as aligned strings instead.
'{0,-18}{1,-22}{2,-12}{3,10}  {4}' -f 'FILENAME', 'SUBJECT', 'EXPIRES', 'DAYSLEFT', 'STATE'
foreach ($r in $report) {
    '{0,-18}{1,-22}{2,-12}{3,10}  {4}' -f $r.FileName, $r.Subject, $r.Expires, $r.DaysLeft, $r.State
}
'{0,-18}{1}' -f 'Rows', @($report).Count

A header, one row per certificate file sorted by name, and a count:

FILENAME          SUBJECT               EXPIRES       DAYSLEFT  STATE
lab-issuing.cer   CN=LAB Issuing CA     2036-01-01        3378  ok
lab-root.cer      CN=LAB Root CA        2036-01-01        3378  ok
rogue-root.cer    CN=ROGUE Root CA      2036-01-01        3378  ok
web-old.cer       CN=old.lab.local      2025-01-01        -639  EXPIRED
web-prod.cer      CN=web.lab.local      2036-01-01        3378  ok
web-rogue.cer     CN=rogue.lab.local    2036-01-01        3378  ok
Rows              6

The order of the tests is the point. EXPIRED is checked before RENEW, so the certificate from 2025 lands in the bucket that will get read first rather than falling through into the merely urgent one, which is the bug from example 3 fixed.

Note: The property is called FileName rather than Name on purpose. PowerShell supplies a Name member on many kinds of object, the ones this loop reads among them, and a calculated property that shadows one is a long afternoon. The same caution applies to Count and Length.

Hidden gems

Validate a certificate as of a date, not as of now. X509ChainPolicy has a VerificationTime property, which is what you want when the question is whether a certificate was valid when something was signed rather than whether it is valid today. Setting it alone does nothing, and this script shows why: it builds the expired certificate twice, pinned to June 2024 both times, changing only one flag.

Save this as Test-VerificationTime.ps1 in C:\pki-lab and run it with .\Test-VerificationTime.ps1.

using namespace System.Security.Cryptography.X509Certificates

# The constructor sets VerificationTime to the time it was called, and Reset()
# replaces it with the current system time rather than the construction time.
# VerificationTimeIgnored decides whether that stored value is used at all, so
# its default is what matters here.
$policy = [X509ChainPolicy]::new()
'{0,-34}{1}' -f 'VerificationTimeIgnored', $policy.VerificationTimeIgnored
'{0,-34}{1}' -f 'Property exists', ([X509ChainPolicy].GetProperty('VerificationTimeIgnored') -ne $null)

# Pin the chain to a date on which the expired leaf was still valid. The stored
# time is only honoured once VerificationTimeIgnored is turned off.
foreach ($ignored in $true, $false) {
    $chain = [X509Chain]::new()
    $chain.ChainPolicy.TrustMode = [X509ChainTrustMode]::CustomRootTrust
    $chain.ChainPolicy.RevocationMode = [X509RevocationMode]::NoCheck
    $chain.ChainPolicy.VerificationTime = [datetime]::new(2024, 6, 1, 0, 0, 0, [DateTimeKind]::Utc)
    $chain.ChainPolicy.VerificationTimeIgnored = $ignored
    $null = $chain.ChainPolicy.CustomTrustStore.Add([X509Certificate2]::new('certs/lab-root.cer'))
    $null = $chain.ChainPolicy.ExtraStore.Add([X509Certificate2]::new('certs/lab-issuing.cer'))
    $ok = $chain.Build([X509Certificate2]::new('certs/web-old.cer'))
    $flags = if ($chain.ChainStatus.Count) { ($chain.ChainStatus.Status -join ',') } else { '(none)' }
    '{0,-34}{1,-7}{2}' -f "VerificationTimeIgnored $ignored", $ok, $flags
}

Two labelled values and two result rows:

VerificationTimeIgnored           True
Property exists                   True
VerificationTimeIgnored True      False  NotTimeValid
VerificationTimeIgnored False     True   (none)

VerificationTimeIgnored reads True out of the constructor, so the stored time is not consulted until it is turned off. With it left alone, the expired leaf fails as it should. With it set to $false, the same chain validates cleanly as of June 2024, when the certificate still had seven months to run. Either behaviour is reasonable; silently getting the first one while believing you asked for the second is not.

Note: The documentation adds a second wrinkle worth knowing if you reuse a chain object: the parameterless constructor sets VerificationTime to the time it was called, and Reset() replaces it with the current system time rather than with the construction time.

Let the provider do the filtering. On Windows, Get-ChildItem -ExpiringInDays 30 against a store is handled by the certificate provider, so you are not loading every certificate and comparing dates in your own loop. It also sidesteps the enhanced key usage filters entirely, which is where the filter-that-widens trap lives.

One helper, many hosts. The handshake helper takes a name, so a list of names is a report. This one needs the listener running again, so start it in a second window first if you have already stopped it.

Save this as New-HostReport.ps1 in C:\pki-lab and run it with .\New-HostReport.ps1.

. ./Get-TlsCertificate.ps1

# A foreach loop is a statement, not an expression, so it cannot be piped
# directly into Export-Csv. Collect it first, exactly as the folder report does.
$report = foreach ($name in 'web.lab.local', 'wrong.lab.local') {
    $r = Get-TlsCertificate -TargetName $name
    [pscustomobject]@{
        TargetName   = $name
        SentByPeer   = $r.SentByPeer      # 1 means the peer sent no intermediate
        PolicyErrors = $r.Errors
        Expires      = $r.Certificate.NotAfter.ToUniversalTime().ToString('yyyy-MM-dd')
    }
}

# The report goes outside the folder the scripts read, or the next pass over
# certs finds this file and tries to parse it as a certificate.
$report | Export-Csv -Path ./tls-report.csv -NoTypeInformation
Get-Content ./tls-report.csv

The CSV it wrote, printed back:

"TargetName","SentByPeer","PolicyErrors","Expires"
"web.lab.local","1","RemoteCertificateChainErrors","2036-01-01"
"wrong.lab.local","1","RemoteCertificateNameMismatch, RemoteCertificateChainErrors","2036-01-01"

Address and port stay at their defaults there, so it walks one listener under two names; point -Address at each host to walk a real estate instead. The SentByPeer column is the one to sort on: every row reading 1 is a host serving its leaf and nothing else, which is the fault example 10 diagnosed.

Common mistake: A foreach loop is a statement rather than an expression, so piping it straight into Export-Csv does not parse: it fails with "An empty pipe element is not allowed." Collect the rows into a variable first, which is what the folder report does as well. And write the CSV outside the folder being read, or the next pass finds its own report and tries to parse it as a certificate.

Clean up when you are finished

The lab is a few things in one folder: the certs subfolder, the helper script, the CSV if you ran the host report and the PEM copies if you ran the openssl script. Nothing was installed into a certificate store and nothing was registered anywhere, so removing them leaves no trace. Stop the listener first if it is still running, then run this from C:\pki-lab.

# Everything this page created lives in the working folder, so these
# three lines remove all of it. Add the scripts themselves with
# Remove-Item *.ps1 if you do not want to keep them.
Remove-Item -Path ./certs -Recurse -Force
Remove-Item -Path ./Get-TlsCertificate.ps1 -Force
Remove-Item -Path ./tls-report.csv -Force -ErrorAction SilentlyContinue
Remove-Item -Path ./*.pem -Force -ErrorAction SilentlyContinue
Note: Keep New-ExpiryReport.ps1 if you keep nothing else. Point it at a real folder of certificates, or swap its Get-ChildItem line for a certificate store path, and it is a working renewal report.

The openssl equivalents

The checks on this page have openssl counterparts, and the split between trust anchors and candidate issuers is the same split under different names: -CAfile is CustomTrustStore and -untrusted is ExtraStore. This one is a shell script rather than PowerShell, so it is for readers on Linux or macOS, or on Windows with openssl installed. Save this as openssl-checks.sh in C:\pki-lab and run it with bash openssl-checks.sh, with the listener running in the other window.

# openssl has to be told the input is DER; it assumes PEM.
openssl x509 -in certs/web-prod.cer -inform DER -noout -subject -issuer -dates

# The expired leaf, for comparison with what PowerShell prints for the same file.
openssl x509 -in certs/web-old.cer -inform DER -noout -subject -dates

# verify wants PEM, so convert the three files first. The copies are written
# beside the scripts, not into certs/, so the lab folder keeps the seven files
# Build-CertLab.ps1 put there and the listing in step 3 stays true.
for f in lab-root lab-issuing web-prod; do
  openssl x509 -in "certs/$f.cer" -inform DER -out "$f.pem"
done

# -CAfile is the trust anchor, -untrusted is the intermediate pool. They are the
# same split as CustomTrustStore and ExtraStore.
echo -n 'with intermediate:    '; openssl verify -CAfile lab-root.pem -untrusted lab-issuing.pem web-prod.pem
echo -n 'without intermediate: '; openssl verify -CAfile lab-root.pem web-prod.pem 2>&1 | tail -n 1

# s_client is the shell equivalent of the SslStream handshake. -servername sets
# the SNI name, which is the name the certificate gets matched against.
echo -n 'off the listener:     '
openssl s_client -connect 127.0.0.1:8443 -servername web.lab.local </dev/null 2>/dev/null |
  openssl x509 -noout -subject -enddate | tr '\n' ' '
echo

Two certificates described, two verify results, and one line off the listener:

subject=CN = web.lab.local
issuer=CN = LAB Issuing CA
notBefore=Jan  1 00:00:00 2026 GMT
notAfter=Jan  1 00:00:00 2036 GMT
subject=CN = old.lab.local
notBefore=Jan  1 00:00:00 2024 GMT
notAfter=Jan  1 00:00:00 2025 GMT
with intermediate:    web-prod.pem: OK
without intermediate: error web-prod.pem: verification failed
off the listener:     subject=CN = web.lab.local notAfter=Jan  1 00:00:00 2036 GMT 

Two things in that output are worth lining up against the PowerShell. openssl prints notAfter=Jan 1 00:00:00 2025 GMT for the expired leaf, in UTC, where example 2 printed 2025-01-01 01:00:00 for the same file; both are the same instant. And dropping -untrusted fails the same certificate that PartialChain failed in example 6, for the same reason.

Note: openssl x509 assumes PEM input and has to be told -inform DER for a .cer file, which is why the conversion loop is there. The OpenSSL cheat sheet has the conversion and inspection commands on one page.

Where this matters

A renewal report that has never caught anything. A guard written as "expiring within thirty days and not yet expired" silently drops every certificate that is already dead, which are the ones worth paging someone about. Example 3.

A service that will not start after a certificate import. HasPrivateKey reading False on the copy in the store is the first thing to rule out, and it is one line to check. Example 1.

A site that loads in a browser and fails from a script. A peer that sends one certificate and no intermediate produces PartialChain, and the fix belongs on the server rather than on every client. Example 10.

A validation script that passes locally and fails in CI. Two machines trust different roots. CustomRootTrust with an explicit trust list removes the machine from the question. Example 5.

A check that hangs in a segregated network. Revocation is online by default and the retrieval timeout is unlimited by default, so both need setting explicitly in anything unattended. Example 8.

A thumbprint that will not match. A binding wants the SHA-1 over the certificate, forty hex characters; a file hashing tool set to SHA-256 gives sixty-four. The length tells you which one you are holding. Example 4.


Tips and limitations

  • Build() is the verdict and ChainStatus is the reason. A clean chain produces an empty status array, not an entry reading NoError.
  • The status flags are a bit field, so test them with HasFlag() rather than -eq: an expired certificate from an untrusted root carries two at once.
  • NotAfter and NotBefore are documented as local time. Keep both sides of a subtraction on the same clock, and never convert just one of them.
  • CustomTrustStore is respected only when TrustMode is CustomRootTrust, and it replaces the machine’s roots for that build rather than adding to them. ExtraStore grants no trust whatsoever.
  • VerificationTime is ignored until VerificationTimeIgnored is set to $false, which it is not by default.
  • The chain examples need PowerShell 7, whose executable is pwsh.exe. CustomTrustStore and TrustMode are .NET 5 and later, so Windows PowerShell 5.1 does not have them.
  • If a script will not run, set the execution policy for the Process scope only. It affects the current session, needs no elevation, and is gone when you close the window.
  • The Cert: drive, its filtering parameters and the pki module are Windows only. No output is published for them here, because they were not run here.
  • A usage filter such as -SSLServerAuthentication also returns certificates with an empty usage list, because the documentation states those can be used for all purposes.
  • Reading a .cer file needs no elevation and no store access. Reading a private key from a store can need both, and nothing on this page asks for one.
  • The thumbprint is forty hex characters. If what you are holding is sixty-four, it is a SHA-256 digest and GetCertHash() with an algorithm name is how to produce one deliberately.
  • Everything the lab creates is worthless outside it. Do not import the lab root into a trust store to "make the examples work": example 5 exists precisely so you never have to.

Official documentation



Five cheat sheets, one PDF

Subnet masks, PowerShell, Linux commands, HTTP status codes and the ESXi command line - one page each, free to keep. Leave an address and it arrives in a minute.