DEV Community

Cover image for SSL Certificate Monitoring in Python: Get Alerted Before Certs Expire
Sameer Sheikh for WhoisFreaks

Posted on

SSL Certificate Monitoring in Python: Get Alerted Before Certs Expire

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)
Enter fullscreen mode Exit fullscreen mode

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.

Certificate chain with three different expiry dates

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"
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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}")
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 []
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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.com came 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 prints OK (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 on YE2. Short-lived certs from large issuers cluster in the 40 to 70 day range, which is a useful baseline. python.org at 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.

Maximum certificate lifetime dropping from 398 to 47 days

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)