Inventory your certificates before one expires in production
Most shops have no central list of their TLS and code-signing certs. Here is a first-pass inventory across every surface, from Certificate Transparency logs to your internal CA, that you can build in under an hour.
On February 3, 2020, Microsoft Teams stopped letting anyone sign in. Not a breach, not a bad deploy: an authentication certificate expired and nobody had renewed it. Engineers diagnosed the cause in about twelve minutes and the service was still down for hours while a replacement cert was issued and traffic rerouted. The company that ships the certificate tooling half the industry runs on got taken out by a calendar entry it missed.
Your shop is not more careful than that. It is just smaller, so when a cert lapses the outage is quieter and the blame lands closer.
The reason is almost never negligence. It is that certificates do not live in one place. They are on your web servers, your load balancers, your internal CA, the management interface of every appliance, the client-auth cert on a service account, the code-signing key on a build agent. Each one was installed by whoever stood up the thing it protects, and the expiry date lives in that person’s memory or nowhere. The first time most teams learn a cert expired is when the pager goes off.
You do not fix that with a platform. You fix it by building a list, assigning each cert an owner and a renewal date, and never again finding out from the help desk queue. The full list takes a project. The first pass takes an hour, and it catches the certs most likely to hurt you. Here is the pass, surface by surface.
1. Public certs: Certificate Transparency logs
Start outside, because the outside is enumerable and you do not need access to anything. Every publicly-trusted TLS certificate issued in the last several years is in a Certificate Transparency log. Those logs are append-only public ledgers: a CA cannot issue a browser-trusted cert without logging it, and once logged it cannot be deleted. Safari and Chrome both refuse certs that are not.
That makes crt.sh a free external view of your own attack surface. Query it for your apex domain with a wildcard and pull JSON:
curl -s 'https://crt.sh/?q=%25.yourdomain.com&output=json' \
| jq -r '.[] | [.name_value, .not_after] | @tsv' \
| sort -u
%25 is a URL-encoded %, the SQL wildcard crt.sh matches on, so this returns every subdomain that has ever had a cert. The name_value field holds the hostnames (a cert with multiple SANs returns them newline-separated, which is why the sort -u is there), and not_after is the expiry. The endpoint is slow and rate-limited on busy days; if it times out, wait and rerun rather than hammering it.
What this catches that nothing else will: the forgotten subdomains. The marketing microsite a contractor stood up in 2023, the staging host somebody exposed to get a Let’s Encrypt cert, the acquired company’s domain still pointing at your load balancer. If it terminated public TLS, it is in the log whether or not you remember it. What it misses: anything internal. Private-CA and self-signed certs never touch a public log, so this surface is blind to your intranet by design.
2. A live endpoint: openssl, one host at a time
crt.sh tells you a cert was issued. It does not tell you what a given service is actually serving right now, which can be an older cert, a renewed one, or a misconfigured chain. For that, ask the endpoint directly:
echo | openssl s_client -connect www.yourdomain.com:443 -servername www.yourdomain.com 2>/dev/null \
| openssl x509 -noout -subject -issuer -enddate
The echo | feeds an empty line so s_client disconnects instead of hanging; -servername sends SNI so a multi-tenant host returns the right cert; 2>/dev/null drops the handshake noise. The openssl x509 side prints the subject, the issuing CA, and notAfter, which is the real expiry of the cert in production.
To script it across a host list, -checkend returns a nonzero exit code if the cert expires within N seconds, so you can flag the ones that need attention without eyeballing dates:
# 2592000 seconds = 30 days
echo | openssl s_client -connect host:443 -servername host 2>/dev/null \
| openssl x509 -noout -checkend 2592000 \
|| echo "host: cert expires within 30 days"
This is the check for load balancers, reverse proxies, and appliance management interfaces, the boxes that will not run an inventory agent. It only sees one listener at a time, and it sees nothing about a cert that is loaded in config but not currently bound to a port. Point it at your known VIPs and edge devices and you have covered the surfaces that take the whole business down when they lapse.
3. Internal Windows certs: the Cert: provider
Now go inside the hosts. PowerShell exposes every Windows certificate store as the Cert: drive, and Get-ChildItem has a dynamic parameter that does the expiry math for you:
Get-ChildItem -Path Cert:\LocalMachine\My -ExpiringInDays 45 |
Select-Object Subject, NotAfter, Thumbprint, DnsNameList
-ExpiringInDays 45 returns only certs due to lapse in the next 45 days; a value of 0 returns the ones already expired. The My store under LocalMachine holds the machine’s server-auth and client-auth certs: the ones bound to IIS, RDP, WinRM, and services running as the machine account. Run it across the fleet from one management host:
$hosts = (Get-ADComputer -Filter "OperatingSystem -like 'Windows*'").Name
Invoke-Command -ComputerName $hosts -ScriptBlock {
Get-ChildItem Cert:\LocalMachine\My -ExpiringInDays 45 |
Select-Object Subject, NotAfter, Thumbprint
}
This catches the internal certs crt.sh cannot see. It misses everything that is not a Windows host in the My store: appliances, Linux services, and certs parked in application config or a Java keystore, which need their own keytool -list pass.
4. Code-signing certs: the one nobody owns
Code-signing certs are the quietest and the worst to lose. When one expires, existing signatures may still validate if they were timestamped, but you cannot sign a new build, and your release pipeline stops on a Friday. Find them with the same provider, filtered by enhanced key usage:
Get-ChildItem -Path Cert:\ -Recurse -CodeSigningCert |
Select-Object Subject, NotAfter, Thumbprint, PSParentPath
-CodeSigningCert returns certs with Code Signing in their EKU across every store on the box. Run it on build agents and developer machines. What it will not find: signing keys that live in an HSM, a cloud KMS, a CI secret store, or a .pfx sitting in a repo. Those are exactly where mature pipelines keep them, so treat this check as a starting point and chase down where signing actually happens for each product you ship.
5. The internal CA: certutil against AD CS
If you run Active Directory Certificate Services, the CA database already knows every cert it has issued and when each expires. certutil -view dumps it, and -restrict filters it:
certutil -view -restrict "Disposition=20,NotAfter<=now+45:00" -out "RequestID,CommonName,NotAfter" csv > issued-expiring.csv
Disposition=20 means issued (not revoked or failed), and now+45:00 is the documented relative-date syntax for 45 days ahead. If your CA uses column names this rejects, drop the restriction and dump everything, then filter in your spreadsheet:
certutil -view -out "RequestID,CommonName,NotBefore,NotAfter,Disposition" csv > all-issued.csv
This is the authoritative list for anything your own CA signed: domain controllers, machine auth, internal services, VPN. It says nothing about certs from public CAs or a second internal CA, and if a team is quietly running its own ACME issuer, this will not see that either. Reconcile it against surface 1, and the gaps between the two lists are the certs nobody is tracking.
The first-pass map
Each surface catches what the others miss. That is the point of running all five instead of trusting one:
| Surface | How to enumerate | What it catches | What it misses |
|---|---|---|---|
| Public endpoints | crt.sh CT-log query | Every publicly-trusted cert for your domains, including forgotten subdomains and shadow IT | Internal, self-signed, and private-CA certs (never logged) |
| A live TLS endpoint | openssl s_client + x509 | The exact cert a service serves now, its real expiry and issuer | One host at a time; certs not currently bound to a listener |
| Windows machine store | Cert: provider, -ExpiringInDays | Server- and client-auth certs bound to IIS, RDP, WinRM, services | Non-Windows hosts, appliances, keystores, app config |
| Code-signing certs | Get-ChildItem -CodeSigningCert | Signing certs in Windows stores on build and dev machines | Keys in HSMs, cloud KMS, CI secret stores, PFX on disk |
| Internal CA (AD CS) | certutil -view | Every cert your enterprise CA issued, with expiry | Public-CA certs, other internal CAs, rogue ACME issuers |
An hour gets you these five lists. It does not get you the Java keystores, the HSM-held keys, or the appliance that speaks neither SNMP nor a shell you can script. Name those as open items and come back to them; a first pass that admits its blind spots beats a “complete” inventory that quietly isn’t.
What turns these lists into protection is the column none of the commands produce: an owner, and a renewal date on a calendar that pages someone before the cert lapses, not after. This is the same failure mode behind the Secure Boot certificate expirations a lot of shops walked into this year. The certificate was known, the expiry was published years in advance, and it still lapsed, because knowing a date and owning it are different things. Build the list this week. Assign the owners next. The expired cert that takes down your production is already issued, sitting in one of these five surfaces, counting down.
Sources
Share
Related field notes
Get the free CVE triage cheat sheet
Subscribe and we'll email you the one-page triage flow for fresh CVEs. Plus the weekly digest.
Subscribe