Skip to content
ssldays

Guide

Python script to monitor SSL expiry

Async Python to check SSL certificate expiry with ssl module, JSON logs, and thresholds—scales better than shell loops for large host lists.

Updated Apr 10, 2026 807 words

Python check ssl certificate expiry jobs shine when you need concurrent handshakes, structured JSON for Grafana, and the same monitor on Linux runners and GitHub Actions—without spawning hundreds of openssl shells.

Shell loops stall on large portfolios; Python’s ssl and asyncio (or threaded pools) cap concurrency cleanly. Security teams embed expiry probes beside existing Django or FastAPI services with a single requirements.txt line (stdlib-only variants avoid dependencies entirely).

This guide provides a stdlib script, optional asyncio batching, and how to emit metrics—complementing Bash and PowerShell variants.

Platform engineers adopt Python when host counts outgrow bash loops but they still want a single artifact versioned beside application code. Unlike one-off openssl pipes, a Python monitor can tag results with environment, owner team, and renewal ticket URLs before pushing to Slack or a data lake. The same script can run in CI to block releases when staging listeners present fewer than seven days of runway, while production checks stay on a slower cadence so you do not overwhelm small hosting providers.

Treat Python as orchestration, not a replacement for understanding what failed. When the script marks a host EXPIRED, operators still need openssl or browser evidence for the incident record. When it marks WARN, procurement may need lead time for manual OV revalidation that ACME cannot rush. Document which policy tier each hostname belongs to so warning thresholds do not page the wrong rotation.

What to check

Each probe needs hostname, port, and server_hostname for SNI. Read notAfter from the peer cert via ssl.get_server_certificate or a wrapped socket. Verification can stay disabled for date-only monitoring (CERT_NONE) but document that expired and untrusted certs still return dates.

Apply timeouts on socket connect and wrap. Idempotency: reruns should not spam registries; respect asyncio.Semaphore limits.

Separate DNS resolution failures from TLS failures in structured logs. A typo in hosts.txt should surface as DNS_ERROR, not masquerade as expiry. For split-horizon environments, note which resolver the runner uses; corporate DNS may return internal addresses that present different certificates than public resolvers.

Certificate file mtimes on disk are not authoritative. Scheduled Python jobs must always perform network handshakes on the hostname customers resolve. After load balancer changes, rerun probes from an external network segment even when the script succeeded inside the VPC.

After Python reports soonest expiries, validate anomalies with CT search (certificate transparency logs search) and live openssl on edge cases. Cross-reference registrar expiration through how to check domain registration expiry when validation failures cluster around renewal week.

Step-by-step

Stdlib synchronous check

#!/usr/bin/env python3
import ssl
import socket
import sys
from datetime import datetime, timezone

def check(host: str, port: int = 443, warn_days: int = 30) -> dict:
    ctx = ssl.create_default_context()
    ctx.check_hostname = False
    ctx.verify_mode = ssl.CERT_NONE
    with socket.create_connection((host, port), timeout=12) as sock:
        with ctx.wrap_socket(sock, server_hostname=host) as ssock:
            cert = ssock.getpeercert(binary_form=False)
            not_after = datetime.strptime(cert["notAfter"], "%b %d %H:%M:%S %Y %Z")
            not_after = not_after.replace(tzinfo=timezone.utc)
            days = (not_after - datetime.now(timezone.utc)).days
            status = "EXPIRED" if days < 0 else ("WARN" if days < warn_days else "OK")
            return {
                "host": host,
                "port": port,
                "not_after": not_after.isoformat(),
                "days_left": days,
                "status": status,
                "subject": dict(x[0] for x in cert.get("subject", [])),
            }

if __name__ == "__main__":
    host = sys.argv[1]
    print(check(host))

Batch from file with concurrency

import asyncio
import json

async def check_async(host: str, sem: asyncio.Semaphore, warn_days: int = 30):
    loop = asyncio.get_running_loop()
    async with sem:
        return await loop.run_in_executor(None, lambda: check(host, warn_days=warn_days))

async def main(hosts, workers=16):
    sem = asyncio.Semaphore(workers)
    tasks = [check_async(h.strip(), sem) for h in hosts if h.strip()]
    return await asyncio.gather(*tasks, return_exceptions=True)

if __name__ == "__main__":
    with open("hosts.txt") as f:
        hosts = f.readlines()
    results = asyncio.run(main(hosts))
    print(json.dumps([r for r in results if not isinstance(r, Exception)], indent=2))

Exit nonzero for monitoring

results = asyncio.run(main(open("hosts.txt").readlines()))
bad = [r for r in results if isinstance(r, dict) and r["status"] in ("WARN", "EXPIRED")]
sys.exit(1 if bad else 0)

GitHub Actions snippet

- name: SSL expiry gate
  run: python3 scripts/ssl_expiry.py api.example.com

Fail deploys when staging certs slip below policy.

Emit Prometheus textfile (optional)

for r in ok_results:
    print(f'ssl_cert_days_remaining{{host="{r["host"]}"}} {r["days_left"]}')

Node exporter textfile_collector ingests the .prom file.

How to read the output

JSON includes ISO not_after, integer days_left, and status. Exceptions in gather mean DNS or timeout—log them separately from EXPIRED.

getpeercert dates are GMT strings parsed as UTC. Compare with openssl when debugging off-by-one:

echo | openssl s_client -connect HOST:443 -servername HOST 2>/dev/null | openssl x509 -noout -dates

Sort batch JSON by days_left ascending before sharing with leadership. Hosts tied on the same not_after often share one renewal automation job—one fix clears multiple rows. Hosts with divergent issuers in the subject map signal multi-cloud sprawl; assign owners per row instead of assuming one platform team renews everything.

When days_left hovers just above your warning threshold, remember that clock skew between runner and CA rarely matters, but grace periods in monitoring tools do. If your script floors partial days, a host showing eight days left might trip a seven-day PagerDuty rule tomorrow even though openssl still prints a comfortable notAfter.

Store historical JSON in object storage and diff serial numbers after renewals. Successful automation should change issuer or serial; if only not_after moves forward while serial stays flat, you might be seeing a CDN edge update that has not reached all regions yet.

Common mistakes

Setting verify_mode=CERT_REQUIRED without a proper trust store makes every corporate MITM proxy fail before you read dates—use CERT_NONE only for expiry dashboards, not for trust audits.

Unbounded run_in_executor threads exhaust file descriptors; always use a semaphore.

Parsing host lists copied from CSV with headers passes the word hostname into TLS—strip headers like the CSV batch guide.

Shipping private keys in the same repo as the monitor script—keep monitoring read-only and host lists in encrypted vaults.

Forgetting IPv6 (AAAA) when dual-stack clients fail on v6-only paths—optional second probe if policy requires.

Running monitors only from corporate laptops that trust an internal root hides untrusted-chain failures that mobile users see. Emit a second pass with default trust if you later add chain validation.

Hardcoding warning thresholds globally ignores that production may use thirty-day alerts while experimental labs intentionally use short-lived certs. Encode tier in hosts.txt or a sidecar YAML and map tiers to different warn_days values in code.

Logging full subject DN strings to public SaaS log vendors may leak customer hostnames you are not allowed to export. Redact or hash hostnames in shared telemetry when contracts require it.

When to use automation

Run Python monitors on Kubernetes CronJobs or Lambda with layers (watch cold start and outbound 443). Push JSON to S3 and diff daily for new WARN rows.

Integrate with PagerDuty via exit codes or custom metrics. Combine TLS JSON output with RDAP registrar scripts in one pipeline for executive reports.

Use ssldays for external validation of critical hosts weekly while Python sweeps the long tail nightly. After fleet migrations, regenerate hosts.txt from CMDB API—not stale spreadsheets.

Schedule staggered cron minutes per region so three data centers do not open three thousand TLS sessions at once. Back off with jitter when ERROR rates spike above baseline; transient provider outages should not look like mass expiry.

Pair Python output with change management: when WARN fires, attach the JSON blob to the renewal ticket so reviewers see exact not_after captured before and after deploy. Automate ticket creation only after deduplicating hosts that share the same load balancer certificate.

For regulated environments, retain audit logs proving checks ran even when status was OK. Compliance reviewers care about missing monitoring days, not only about incidents you caught.

Document runner egress IP ranges in the monitoring repo so network teams whitelist probes deliberately rather than chasing phantom firewall drops each quarter. When providers rate-limit you, temporarily halve concurrency and record the incident—do not permanently disable checks on the long tail.

Version the script with semantic tags and note breaking changes to JSON field names. Dashboards break silently when days_left becomes daysRemaining without notice. Add a schema version field at the top level of each emitted object so downstream transformers can branch safely.

During acquisitions, importers should normalize internationalized domain names to punycode before connecting. Python 3 handles IDNA in server_hostname when you pass the unicode form, but logging and deduplication stay clearer when everyone stores ACE strings in hosts.txt.

Data science teams joining security operations sometimes overfit anomaly models on days_left trends without understanding ninety-day ACME baselines. Pair statistical dashboards with deterministic openssl spot checks before declaring false renewal emergencies.

Packaging the monitor as a wheel on an internal index lets application teams pin dependencies per microservice while platform engineering ships security fixes centrally. Document upgrade paths when Python minor versions deprecate ssl module behaviors.

Greenfield startups may postpone Python monitors until host count exceeds fifty; define that threshold in writing so premature shell scripts get replaced before technical debt becomes embarrassing during investor diligence.

Observability vendors charging per host may encourage sampling your long tail. Resist sampling on production revenue hosts even when vendors recommend it; uniform policy on top N names plus sampled tail is a pragmatic compromise.

Post-incident reviews should ask whether Python monitors would have fired early enough with your chosen warn_days. Tune thresholds based on actual time to renew and deploy, not generic thirty-day defaults copied from blog posts.

Type-annotate public functions and publish mypy results in CI so refactors do not silently break optional port parameters used by a minority of internal API gateways.

Offer a --json-lines flag in your internal fork so log aggregators ingest one object per line without fragile multiline pretty printing that breaks jq pipelines during incidents.

Platform SREs adopting python check ssl certificate expiry monitors often integrate with existing Django Celery workers to avoid new infrastructure. A periodic task reuses broker connections and admin tooling already on call for other queues, lowering marginal cost per hostname checked.

Data retention policies should define how long JSON history lives in cold storage. Seven years of daily checks for ten thousand hosts grows quickly; compress archives and sample OK rows while retaining every WARN and EXPIRED event for compliance narratives.

Chaos engineering exercises can deliberately introduce synthetic WARN rows in staging monitor output to verify PagerDuty routes and Slack channel wiring before production certificates actually slip during change freezes or holiday on-call rotations with reduced staffing.

Open source contributors wrapping your internal monitor should strip customer hostnames before publishing example output in GitHub issues. Redaction discipline starts in scripts that default to printing only status enums in public logs.

Science teams running Jupyter on shared clusters appreciate notebook cells calling the same check function for ad hoc research queries against partner APIs without waiting for platform tickets.

Sustainability officers occasionally ask about energy impact of constant TLS probes. Batch scheduling during off-peak grid hours and adaptive concurrency when fleets are quiet answer those questions without abandoning monitoring entirely.

Internal developer conferences are a good venue to demo the monitor CLI on stage with anonymized hostnames, reinforcing python check ssl certificate expiry literacy outside the platform guild and encouraging application teams to submit pull requests for missing hosts.

Runbooks should note which on-call rotation owns the monitor repository versus the hosts list repository, because paging the wrong team wastes minutes when WARN rows multiply during partial platform outages.

Vendor penetration tests sometimes flag monitoring scripts as shadow IT; register the repository in your software inventory with data classification and approved outbound destinations before assessors open findings about unapproved TLS scanning against production names you already monitor legitimately.

Frequently asked questions

How is this different from OpenSSL subprocess calls?

Subprocesses work but cost fork overhead and complicate Windows. Stdlib ssl is portable; subprocess openssl matches openssl verify flags exactly when you need parity.

Can Python check mail STARTTLS?

Yes, with smtplib and starttls(), then read the negotiated cipher’s cert—more code than OpenSSL one-liners. Prefer the dedicated mail TLS expiry guide for MX focus.

Is asyncio always faster?

For hundreds of hosts, yes with sane limits. For five hosts, a simple loop is clearer.

What Python version?

3.9+ for modern asyncio and type hints. 3.12+ recommended for long-term security support.

See what the server presents right now

Free live certificate check — no signup.