Certbot had been renewing that certificate for two years without anyone touching it. Then someone moved the webroot during a Nginx cleanup, the HTTP-01 challenge started failing, and the renewal cron kept exiting quietly with a non-zero status nobody was reading. We found out when the cert expired.
That's the shape of almost every certificate outage I've seen. Not "we forgot to buy a certificate." More like "the automation was running and we assumed running meant working."
So I wrote a script that checks the certificate that's actually being served, from outside my own network, and shouts before anything breaks. It's about 400 lines and it caught something on the first real run that I wasn't expecting.
The part most expiry checks get wrong
Search for how to check certificate expiry in Python and you'll get this, more or less:
import ssl, socket
from datetime import datetime, timezone
ctx = ssl.create_default_context()
with socket.create_connection(("github.com", 443), timeout=5) as sock:
with ctx.wrap_socket(sock, server_hostname="github.com") as tls:
cert = tls.getpeercert()
not_after = datetime.strptime(cert["notAfter"], "%b %d %H:%M:%S %Y %Z")
print((not_after.replace(tzinfo=timezone.utc) - datetime.now(timezone.utc)).days)
This works. No dependencies, five seconds to write, and for a handful of hosts you can reach directly it's the right answer. I keep a copy in the repo as check_expiry_stdlib.py because sometimes that's all you need.
But getpeercert() gives you the leaf certificate and nothing else. Your leaf can have 60 comfortable days left while the intermediate that signs it expires next week, and when that intermediate goes, every client gets a handshake failure. The leaf's date told you nothing.
This isn't hypothetical. LogDNA published a postmortem in May 2020 about exactly this: a root certificate expiry that broke TLS for their customers while the leaf certificates were all perfectly valid.
There's a second problem, and it's the one that bit me. getpeercert() reports on the TLS connection that your script made. If anything sits between your script and the internet doing TLS inspection, a corporate proxy, a security appliance, a container platform with an egress gateway, then you are reading that middlebox's re-signed certificate. Not the real one. Your monitoring reports healthy and the outside world sees an expired cert.
I hit this while testing. Running the stdlib script inside a sandboxed environment, every host came back valid with 29 to 30 days left, including expired.badssl.com, which is a host whose entire purpose is to serve an expired certificate. The issuer field said the proxy's name. My checker was confidently monitoring the wrong thing.
So: query from somewhere else, and get the whole chain.
What I built
wf-ssl-monitor reads a list of domains, pulls the full certificate chain for each one through the WhoisFreaks SSL Certificate API, finds whichever certificate in the chain expires first, and exits with a Nagios-style status code so cron or CI can act on it. Optionally it posts to Slack.
Full source: github.com/WhoisFreaks/wf-ssl-monitor
Setup
git clone https://github.com/WhoisFreaks/wf-ssl-monitor.git
cd wf-ssl-monitor
pip install -r requirements.txt
export WHOISFREAKS_API_KEY="your_key"
Signup gives you 500 credits without a card, which is plenty to get this running. The only dependency is requests.
Step 1: One call, whole chain
The endpoint is a single GET. The chain=true parameter is the important one, since without it you get the leaf and you're back to the problem above.
curl "https://api.whoisfreaks.com/v1.0/ssl/live?apiKey=$WHOISFREAKS_API_KEY&domainName=github.com&chain=true"
You get back sslCertificates, an array ordered from end-user to root. Each entry carries chainOrder, validityStartDate, validityEndDate, subject, issuer, signatureAlgorithm, publicKey, and the SANs under extensions.subjectAlternativeNames.dnsNames.
One detail worth flagging because it cost me a debugging round. The dates come back like this:
2026-09-1 07:47:19 UTC
The day isn't zero-padded. strptime with %d handles it, but a naive dateutil guess or a hand-rolled slice will not, and the failure mode is silent wrong numbers rather than an exception. That's the worst kind. So the parser is explicit and tries several shapes:
def parse_wf_datetime(raw: str) -> datetime:
s = raw.strip()
for suffix in (" UTC", "UTC", " GMT", "Z"):
if s.endswith(suffix):
s = s[: -len(suffix)].strip()
break
for fmt in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%dT%H:%M:%S",
"%Y-%m-%d %H:%M", "%Y-%m-%d", "%b %d %H:%M:%S %Y"):
try:
return datetime.strptime(s, fmt).replace(tzinfo=timezone.utc)
except ValueError:
continue
raise ValueError(f"unrecognised date format: {raw!r}")
Note datetime.now(timezone.utc) rather than datetime.utcnow() everywhere in this script. utcnow() is deprecated as of Python 3.12 and returns a naive datetime, which means subtracting it from an aware one raises. If you're copying older tutorials, that's a trap.
Step 2: Alert on the weakest link, not the leaf
This is the whole point of the tool, and it's about eight lines. Parse every certificate in the chain, then sort by expiry and take the earliest:
@property
def weakest(self) -> CertInfo | None:
"""The cert in the chain that expires first. This is what we alert on."""
scored = [c for c in self.certs if c.days_left is not None]
return min(scored, key=lambda c: c.days_left) if scored else None
Classification runs off min_days across the chain rather than the leaf's date:
def classify(result: DomainResult, warn: int, crit: int) -> str:
if result.error:
return STATUS_ERROR
days = result.min_days
if days is None:
return STATUS_ERROR
if days < 0:
return STATUS_EXPIRED
if days <= crit:
return STATUS_CRIT
if days <= warn:
return STATUS_WARN
return STATUS_OK
Defaults are 30 days for warning and 7 for critical. Both are flags, and I'd tighten them as certificate lifetimes shrink.
Step 3: Respect the rate limit before it bites
The free tier allows 10 requests per minute on live endpoints. Firing a thread pool at that gets you a wall of 429s, so requests get paced through a small limiter that holds the lock while it sleeps:
class RateLimiter:
"""Spaces requests out so we stay under the plan's requests-per-minute cap."""
def __init__(self, rpm: int):
self.min_interval = 60.0 / rpm if rpm > 0 else 0.0
self._lock = threading.Lock()
self._next_slot = 0.0
def acquire(self) -> None:
if self.min_interval <= 0:
return
with self._lock:
now = time.monotonic()
wait = self._next_slot - now
if wait > 0:
time.sleep(wait)
now = time.monotonic()
self._next_slot = max(now, self._next_slot) + self.min_interval
If a 429 does come back, the API tells you exactly how long to wait in the x-ratelimit-remaining-time header. The value is in nanoseconds, which surprised me:
if resp.status_code == 429:
raw_wait = resp.headers.get("x-ratelimit-remaining-time")
try:
pause = min(int(raw_wait) / 1_000_000_000, 90)
except (TypeError, ValueError):
pause = 15.0
if attempt < 2:
time.sleep(max(pause, 1.0))
continue
On a paid plan, --rpm 60 --workers 8 moves considerably faster.
Step 4: Don't trust the documented response shape
I built the parser against the response structure in the docs, which shows a top-level JSON array. First real run against the live API, every domain came back as an error while the HTTP status was 200. The data was fine. My assumption about the wrapper was wrong, and because I'd written if isinstance(payload, dict): return error, a perfectly good response got thrown away.
Lesson I keep relearning: match on field names, not on payload structure. The parser now accepts an array, a bare object, a single certificate with no wrapper, or data nested under an envelope key:
def find_cert_entries(payload: Any) -> list[dict]:
if isinstance(payload, list):
out = []
for item in payload:
if isinstance(item, dict):
out.extend(find_cert_entries(item))
return out
if not isinstance(payload, dict):
return []
if payload.get("sslCertificates"):
certs = payload["sslCertificates"]
if isinstance(certs, dict):
payload = {**payload, "sslCertificates": [certs]}
return [payload]
for key in ("data", "result", "results", "response", "ssl", "sslData"):
nested = payload.get(key)
if isinstance(nested, (dict, list)):
found = find_cert_entries(nested)
if found:
return found
if payload.get("validityEndDate") or payload.get("subject"):
return [{"domainName": payload.get("domainName"),
"sslCertificates": [payload]}]
return []
And when it still can't find certificates, it says what it did receive instead of failing generically:
error=f"no certificate data found ({shape})"
# -> no certificate data found (object with keys: domainName, queryTime, someNewKey)
It keeps the raw payload and prints it automatically. Telling someone to rerun with a debug flag is a wasted scheduled run, and if that run was at 3 AM you've lost a day.
Real results
Running against the production list:
$ python ssl_monitor.py domains.txt --warn 30 --crit 7
DOMAIN DAYS EXPIRES ISSUER STATUS
------------------------------------------------------------------------------------
whoisfreaks.com 70 2026-10-30 WE1 OK
github.com 41 2026-09-30 Sectigo Public Server Authentication C OK
cloudflare.com 47 2026-10-06 WE1 OK
python.org 178 2027-02-14 GlobalSign Atlas R3 DV TLS CA 2025 Q4 OK
letsencrypt.org 45 2026-10-04 YE2 OK
5 domains in 26.4s - 5 ok, 0 warning, 0 critical, 0 expired, 0 errors
Then the deliberately broken list, which is how I check the checker:
$ python ssl_monitor.py domains.test.txt
DOMAIN DAYS EXPIRES ISSUER STATUS
------------------------------------------------------------------------------------------
expired.badssl.com -4148 2015-04-12 COMODO RSA Domain Validation Secure Se EXPIRED
self-signed.badssl.com 728 2028-08-17 *.badssl.com OK (self-signed)
2 domains in 12.1s - 1 ok, 0 warning, 0 critical, 1 expired, 0 errors
Negative 4,148 days. That certificate expired in April 2015 and the API reported it accurately, which is exactly what I wanted to confirm. Remember the sandbox where every host including this one came back with 30 healthy days? That's the difference between checking the certificate and checking whatever your network hands you.
What I noticed
- A valid expiry date is not a valid certificate.
self-signed.badssl.comcame back with 728 days left and my first version called that OK. It's self-signed. Every browser rejects it. I added a check comparing the leaf's subject against its issuer, and now it printsOK (self-signed), because reporting green on a cert nothing trusts is worse than not checking at all. - The intermediate is the one nobody watches. Every dashboard I've used reports the leaf date.
- Errors deserve their own status, separate from expiry. A lookup that fails is not a healthy certificate, and folding those together is how you end up with a green dashboard and a broken site. Hence exit code 3.
- Two of the five production hosts are on Google Trust Services (
WE1) and one onYE2. Short-lived certs from large issuers cluster in the 40 to 70 day range, which is a useful baseline.python.orgat 178 days stood out immediately as the outlier running a longer GlobalSign cert. - Five domains took 26 seconds. That's the rate limiter doing its job at 10 requests a minute, not the API being slow.
- Keep your broken test hosts in a separate file from the list your scheduled job reads. I didn't, and my first green-field cron run went red at 8 AM because a host that expired in 2015 was still expired. A monitor that cries wolf every morning gets muted by Thursday.
Why this is getting more urgent
Certificate lifetimes are dropping fast. CA/Browser Forum ballot SC-081v3 passed in April 2025 and steps the maximum down on a fixed schedule.
Let's Encrypt is moving in parallel, from 90 days now to 64 in February 2027 and 45 in February 2028.
Here's the thing about going from roughly one renewal a year to nearly eight: the per-renewal failure rate doesn't have to change for your annual odds of an outage to get much worse. Manual renewal stops being viable, automation becomes mandatory, and automation that fails silently becomes the main risk. Which is the argument for monitoring the served certificate rather than trusting that the renewal job did its job.
Scheduling it
The repo has a GitHub Actions workflow that runs daily at 08:00 UTC:
on:
schedule:
- cron: '0 8 * * *'
workflow_dispatch:
concurrency:
group: ssl-check
cancel-in-progress: false
workflow_dispatch is there so you can trigger it by hand while testing instead of editing the cron and waiting. concurrency stops a slow run overlapping the next one and double-alerting. Store WHOISFREAKS_API_KEY and SLACK_WEBHOOK_URL under Settings, Secrets and variables, Actions.
For a plain cron box:
0 8 * * * cd /opt/wf-ssl-monitor && /usr/bin/python3 ssl_monitor.py domains.txt --slack
Going further
A few directions I'd take this next:
- Validate trust, not just dates. The self-signed catch above is a string comparison. Real validation means checking the chain actually links up and terminates at a trusted root, which is a bigger job and probably the most valuable thing to add next.
- Pull the domain list from your actual infrastructure instead of a text file. Terraform state, a DNS zone export, or your ingress definitions all beat a hand-maintained list that drifts.
- Alert on SAN changes, not just expiry. A certificate that quietly stops covering a hostname is its own outage.
- Write results to a time series database so you can see renewals happening rather than just current state. A cert stuck at the same expiry date across three weeks is a renewal that stopped working.
- If you'd rather not run and maintain this yourself, WhoisFreaks SSL Certificate API covers the lookup side as a managed service.
Full source
Everything is in github.com/WhoisFreaks/wf-ssl-monitor, MIT licensed. That includes ssl_monitor.py, the stdlib version for comparison, the Actions workflow, a production domains.txt, and a domains.test.txt of deliberately broken hosts for verifying the checker.
If you run it and hit a response shape the parser doesn't handle, open an issue with the payload it printed. That's the part most likely to need adjusting.


Top comments (0)