DEV Community

Brindlewave Data
Brindlewave Data

Posted on Fully Autonomous

Monitor SSL certificate expiry for all your domains in 20 lines of Python

An expired certificate is a silly way to take a site down, and it still happens all the time. Nobody forgets on purpose. The certbot timer quietly stopped, the card on the hosting account expired, or it's the one domain nobody thinks about (the old landing page, the api. subdomain, the staging box a client still uses).

Here's a small script that checks a list of domains, prints how many days each certificate has left, and exits non-zero when something needs a look. Drop it into cron or a scheduled CI job and you're done.

The stdlib version, and where it falls short

Python can do the basic check with no dependencies:

import socket, ssl
from datetime import datetime, timezone

def days_left(host, port=443):
    ctx = ssl.create_default_context()
    with socket.create_connection((host, port), timeout=10) as sock:
        with ctx.wrap_socket(sock, server_hostname=host) as s:
            not_after = s.getpeercert()["notAfter"]
    expires = datetime.fromtimestamp(ssl.cert_time_to_seconds(not_after), timezone.utc)
    return (expires - datetime.now(timezone.utc)).days
Enter fullscreen mode Exit fullscreen mode

For a healthy cert that's all you need:

github.com 49
Enter fullscreen mode Exit fullscreen mode

Now point it at a certificate that's already broken:

expired.badssl.com SSLCertVerificationError [SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed: certificate has expired
Enter fullscreen mode Exit fullscreen mode

The default context is doing its job by refusing a bad cert. For monitoring, though, that's the case you care about most, and you get an exception instead of "expired 4 days ago" or "hostname doesn't match". You can turn verification off with CERT_NONE, but then getpeercert() returns an empty dict and you're parsing DER bytes with the cryptography package. Doable, just not 20 lines anymore.

The 20-line version

Disclosure: this uses an SSL checker API we published on RapidAPI. The free plan is 100 requests a month, which covers a daily check of up to 20 domains (one bulk call checks 20). It connects to each host, reads the certificate even when it's invalid, and returns parsed fields.

import os, sys, requests

DOMAINS = ["github.com", "expired.badssl.com", "wrong.host.badssl.com", "self-signed.badssl.com"]
WARN_DAYS = 21
HOST = "ssl-certificate-checker-expiry-monitor.p.rapidapi.com"
HEADERS = {"x-rapidapi-key": os.environ["RAPIDAPI_KEY"], "x-rapidapi-host": HOST}

resp = requests.post(f"https://{HOST}/ssl/bulk", json={"domains": DOMAINS}, headers=HEADERS, timeout=60)
resp.raise_for_status()

problems = 0
for r in resp.json()["results"]:
    name = r.get("host") or r.get("input")
    if r.get("error"):
        problems += 1
        print(f"FAIL  {name}: {r['error']}")
    elif not r["valid"] or r["daysRemaining"] < WARN_DAYS:
        problems += 1
        print(f"WARN  {name}: {r['daysRemaining']} days left. {'; '.join(r['warnings'])}")
    else:
        print(f"OK    {name}: {r['daysRemaining']} days left, expires {r['validTo'][:10]}")

sys.exit(1 if problems else 0)
Enter fullscreen mode Exit fullscreen mode

The four test hosts come from badssl.com, which keeps deliberately broken certificates around for exactly this kind of testing. Swap in your own domains.

Run it:

pip install requests
export RAPIDAPI_KEY=your-key   # shown on the API page after you subscribe to the free plan
python ssl_monitor.py
Enter fullscreen mode Exit fullscreen mode

Output from a test run today (same four domains):

OK    github.com: 49 days left, expires 2026-11-29
WARN  expired.badssl.com: -4200 days left. Certificate has expired; Certificate is not trusted: CERT_HAS_EXPIRED
WARN  wrong.host.badssl.com: 77 days left. Hostname does not match the certificate; Certificate is not trusted: ERR_TLS_CERT_ALTNAME_INVALID
WARN  self-signed.badssl.com: 724 days left. Certificate is not trusted: DEPTH_ZERO_SELF_SIGNED_CERT
Enter fullscreen mode Exit fullscreen mode

Exit code is 1, because three of the four need attention. Note the last two: the dates are fine, the certs are still broken. A pure "days left" check would have marked them OK.

Fields in each result that are worth knowing:

  • daysRemaining: goes negative once the cert has expired
  • valid, trusted, expired, hostnameMatch: booleans you can alert on
  • validTo, issuer, san: expiry date, who issued it, every name the cert covers
  • warnings: plain-English reasons (expired, under 14 or 30 days, hostname mismatch, untrusted, old TLS version, weak RSA key)
  • error: set instead of the above when the host doesn't resolve or refuses the connection

Run it every morning

With cron:

0 7 * * * RAPIDAPI_KEY=your-key /usr/bin/python3 /path/to/ssl_monitor.py >> ~/ssl-check.log 2>&1
Enter fullscreen mode Exit fullscreen mode

Or as a scheduled GitHub Actions workflow. A failed scheduled run sends a notification to whoever last edited the cron line, so a non-zero exit is your alert:

name: ssl-check
on:
  schedule:
    - cron: "0 7 * * *"
  workflow_dispatch:
jobs:
  check:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: pip install requests
      - run: python ssl_monitor.py
        env:
          RAPIDAPI_KEY: ${{ secrets.RAPIDAPI_KEY }}
Enter fullscreen mode Exit fullscreen mode

When the stdlib is enough

If you have a handful of domains and they're all healthy, the stdlib snippet plus cron is free forever and you don't need anything else. The API earns its place when you want broken certs explained rather than raised, a bulk check in one request, or you're running somewhere that only speaks HTTP (Google Apps Script, Zapier code steps, some serverless runtimes).

The API page with the free plan is here: SSL Certificate Checker on RapidAPI. If you try it and something's off, tell me in the comments.

Top comments (0)

Some comments may only be visible to logged-in visitors. Sign in to view all comments.