DEV Community

Ayi NEDJIMI
Ayi NEDJIMI

Posted on

Building an Automated SSL Certificate Monitor with Alerting

SSL certificate expiration is one of those problems that feels trivial until it brings down your production environment at 2 AM. Most teams only notice it after the fact — when users start seeing browser warnings or when a payment provider refuses the expired cert on a webhook. A proactive monitor costs less than an hour to build and eliminates the category entirely.

This article walks through a production-ready SSL certificate monitor in Python that checks multiple domains, calculates days remaining, and fires alerts via Slack when a cert is about to expire.

What to Check — and What to Ignore

The naive approach — just checking the expiry date — misses several failure modes:

  • The certificate is valid but the hostname doesn't match the CN/SAN (misconfiguration after a domain rename)
  • The intermediate chain is broken (the cert itself is fine, but some clients reject it)
  • The cert was renewed but the server was never restarted, so it still serves the old one

For this monitor we focus on:

  1. Days until expiration
  2. Hostname verification
  3. The actual cert being served (not a cached value)

We skip full chain validation — that adds significant complexity and is better handled by dedicated tools like sslyze in a full audit.

Core Check: Fetching the Live Certificate

Python's ssl and socket modules give us everything we need without external dependencies.

import ssl
import socket
from datetime import datetime, timezone
from dataclasses import dataclass
from typing import Optional


@dataclass
class CertInfo:
    hostname: str
    expires_at: datetime
    days_remaining: int
    subject_cn: str
    issuer: str
    error: Optional[str] = None


def check_cert(hostname: str, port: int = 443, timeout: int = 10) -> CertInfo:
    context = ssl.create_default_context()
    try:
        with socket.create_connection((hostname, port), timeout=timeout) as sock:
            with context.wrap_socket(sock, server_hostname=hostname) as ssock:
                cert = ssock.getpeercert()
    except ssl.SSLCertVerificationError as e:
        return CertInfo(
            hostname=hostname,
            expires_at=datetime.now(timezone.utc),
            days_remaining=-1,
            subject_cn="",
            issuer="",
            error=str(e),
        )
    except (socket.timeout, ConnectionRefusedError, OSError) as e:
        return CertInfo(
            hostname=hostname,
            expires_at=datetime.now(timezone.utc),
            days_remaining=-1,
            subject_cn="",
            issuer="",
            error=str(e),
        )

    not_after = datetime.strptime(cert["notAfter"], "%b %d %H:%M:%S %Y %Z")
    not_after = not_after.replace(tzinfo=timezone.utc)
    days_remaining = (not_after - datetime.now(timezone.utc)).days

    subject = dict(x[0] for x in cert.get("subject", []))
    issuer = dict(x[0] for x in cert.get("issuer", []))

    return CertInfo(
        hostname=hostname,
        expires_at=not_after,
        days_remaining=days_remaining,
        subject_cn=subject.get("commonName", ""),
        issuer=issuer.get("organizationName", ""),
    )
Enter fullscreen mode Exit fullscreen mode

A few things worth noting:

  • ssl.create_default_context() enables hostname verification by default. If the cert's SAN doesn't cover the hostname, you get an SSLCertVerificationError logged as an error, not a days-remaining warning.
  • The error field is populated on connection failures so the caller always gets a structured result, never an unhandled exception.
  • Timezone-aware datetime objects prevent subtle comparison bugs when your monitor runs near midnight in a different timezone from the cert's timestamp.

Checking Multiple Domains with Per-Domain Thresholds

Different domains warrant different urgency windows. A domain serving a payment flow needs an earlier alert than a rarely-used internal tool.

DOMAINS = [
    {"hostname": "example.com", "warn_days": 30, "critical_days": 7},
    {"hostname": "api.example.com", "warn_days": 21, "critical_days": 5},
    {"hostname": "internal.corp.example.com", "port": 8443, "warn_days": 14, "critical_days": 3},
]


class AlertLevel:
    OK = "ok"
    WARN = "warn"
    CRITICAL = "critical"
    ERROR = "error"


def assess(cert: CertInfo, warn_days: int, critical_days: int) -> str:
    if cert.error:
        return AlertLevel.ERROR
    if cert.days_remaining <= critical_days:
        return AlertLevel.CRITICAL
    if cert.days_remaining <= warn_days:
        return AlertLevel.WARN
    return AlertLevel.OK


def run_checks() -> list[dict]:
    results = []
    for domain in DOMAINS:
        hostname = domain["hostname"]
        port = domain.get("port", 443)
        cert = check_cert(hostname, port)
        level = assess(cert, domain["warn_days"], domain["critical_days"])
        results.append({"cert": cert, "level": level, "config": domain})
    return results
Enter fullscreen mode Exit fullscreen mode

Sending Slack Alerts

Slack webhooks are the simplest production-friendly alerting path. No SMTP credentials, no external packages, and messages appear instantly in a monitored channel.

import json
import urllib.request

SLACK_WEBHOOK_URL = "https://hooks.slack.com/services/YOUR/WEBHOOK/URL"

LEVEL_EMOJI = {
    AlertLevel.OK: ":white_check_mark:",
    AlertLevel.WARN: ":warning:",
    AlertLevel.CRITICAL: ":rotating_light:",
    AlertLevel.ERROR: ":x:",
}


def send_slack_alert(results: list[dict]) -> None:
    issues = [r for r in results if r["level"] != AlertLevel.OK]
    if not issues:
        return  # no noise when everything is fine

    lines = []
    for r in issues:
        cert = r["cert"]
        emoji = LEVEL_EMOJI[r["level"]]
        if cert.error:
            lines.append(f"{emoji} *{cert.hostname}* — connection error: `{cert.error}`")
        else:
            lines.append(
                f"{emoji} *{cert.hostname}* — expires in *{cert.days_remaining} days* "
                f"({cert.expires_at.strftime('%Y-%m-%d')}) — issued by {cert.issuer}"
            )

    message = {"text": "SSL Certificate Monitor\n" + "\n".join(lines)}
    data = json.dumps(message).encode()
    req = urllib.request.Request(
        SLACK_WEBHOOK_URL,
        data=data,
        headers={"Content-Type": "application/json"},
    )
    urllib.request.urlopen(req, timeout=10)
Enter fullscreen mode Exit fullscreen mode

Only alert on issues. If you send an "all certs OK" message every run, on-call engineers will tune it out within a week. Alert on deviation, not on health.

For email alerts, swap the Slack call for smtplib.SMTP_SSL — the structure is identical.

Scheduling Without Cron Complexity

A cron job works for most teams:

0 8 * * * /usr/bin/python3 /opt/monitors/ssl_monitor.py >> /var/log/ssl_monitor.log 2>&1
Enter fullscreen mode Exit fullscreen mode

If you prefer systemd (better logging, restart-on-failure):

# /etc/systemd/system/ssl-monitor.timer
[Unit]
Description=Run SSL monitor daily at 08:00

[Timer]
OnCalendar=*-*-* 08:00:00
Persistent=true

[Install]
WantedBy=timers.target
Enter fullscreen mode Exit fullscreen mode

The Persistent=true flag matters: if the server was down at the scheduled time, systemd executes the job as soon as it comes back up — no silently missed checks.

Hardening the Monitor Itself

A few operational concerns that are easy to miss:

Handle transient failures without silencing real alerts. Networks hiccup. Add a retry with short backoff before concluding a host is unreachable, but cap at 2–3 retries — you don't want the monitor hanging for minutes because one host is flaky.

Log every check, not just failures. When a cert eventually expires and someone asks "why didn't we catch this sooner?", you want a complete run history. Structured JSON logs — one dict per domain per run — make this trivially queryable with jq.

Never hardcode the Slack webhook URL. Use an environment variable or a secrets manager. Webhook URLs are credentials; rotating them after an accidental commit is friction you don't want. Apply the same discipline you would to any API key.

For teams running hardened infrastructure, pairing this monitor with a documented TLS configuration checklist makes incident response much faster. We publish a free SSL/TLS security hardening checklist in PDF and Excel format if you need a reference to build your runbook against.

The Takeaway

The monitor above runs in under 120 lines of standard-library Python, requires no external packages, and catches the three most common SSL failure modes before they affect users. The key decisions to carry forward:

  • Always check the live cert, not a cached DNS or CDN value
  • Separate warn and critical thresholds per domain — a single global threshold creates alert fatigue
  • Log every run, alert only on deviation
  • Use systemd timers over cron for anything that needs persistence guarantees

The logical next extension is certificate transparency log monitoring — CT logs will tell you if someone issued a cert for your domain without your knowledge, which is a separate and more serious threat. But that's a subject for another post.


I run AYI NEDJIMI Consultants, a cybersecurity consulting firm. We publish free security hardening checklists — PDF and Excel.

Top comments (0)