SSL certificate expiry is one of the most avoidable causes of production outages. The certificate is usually set to expire a year or more out, and then somebody forgets to renew it. Browser warnings, API call failures, and angry users follow. The fix is straightforward: automated monitoring.
Python's standard library includes everything you need to check SSL certificate expiry without installing any third-party packages. Here's how to do it, from a single domain check to a full monitoring script with email alerts.
ssl module (no dependencies)Python's ssl and socket modules let you connect to any HTTPS host and inspect its certificate. No third-party libraries required:
import ssl
import socket
from datetime import datetime
def get_ssl_expiry(hostname: str, port: int = 443) -> dict:
"""Get SSL certificate expiry info for a hostname."""
context = ssl.create_default_context()
try:
with socket.create_connection((hostname, port), timeout=10) as sock:
with context.wrap_socket(sock, server_hostname=hostname) as ssock:
cert = ssock.getpeercert()
except ssl.SSLCertVerificationError as e:
return {"hostname": hostname, "error": str(e), "valid": False}
except (socket.timeout, ConnectionRefusedError) as e:
return {"hostname": hostname, "error": str(e), "valid": False}
# cert['notAfter'] looks like: 'Dec 25 23:59:59 2025 GMT'
expiry_str = cert['notAfter']
expiry_date = datetime.strptime(expiry_str, '%b %d %H:%M:%S %Y %Z')
days_remaining = (expiry_date - datetime.utcnow()).days
return {
"hostname": hostname,
"expires_at": expiry_date.isoformat(),
"days_remaining": days_remaining,
"issuer": dict(x[0] for x in cert['issuer']).get('organizationName', 'Unknown'),
"valid": days_remaining > 0,
}
# Example usage
result = get_ssl_expiry("example.com")
print(f"{result['hostname']}: {result['days_remaining']} days remaining")
# → example.com: 82 days remaining
This works for any publicly reachable HTTPS host. It respects the certificate chain, verifies against system CA roots, and raises a proper error if the certificate is already invalid.
For most real-world use cases, you need to check more than one domain. Here's a script that checks a list and prints a summary:
import ssl
import socket
from datetime import datetime
DOMAINS = [
"example.com",
"api.yourapp.com",
"staging.yourapp.com",
"mail.yourapp.com",
]
WARN_THRESHOLD_DAYS = 30
CRITICAL_THRESHOLD_DAYS = 7
def get_ssl_expiry(hostname: str, port: int = 443) -> dict:
context = ssl.create_default_context()
try:
with socket.create_connection((hostname, port), timeout=10) as sock:
with context.wrap_socket(sock, server_hostname=hostname) as ssock:
cert = ssock.getpeercert()
expiry_date = datetime.strptime(cert['notAfter'], '%b %d %H:%M:%S %Y %Z')
days_remaining = (expiry_date - datetime.utcnow()).days
return {"hostname": hostname, "days_remaining": days_remaining,
"expires_at": expiry_date.isoformat(), "error": None}
except Exception as e:
return {"hostname": hostname, "days_remaining": -1,
"expires_at": None, "error": str(e)}
def main():
results = [get_ssl_expiry(domain) for domain in DOMAINS]
for r in results:
if r["error"]:
print(f"❌ {r['hostname']}: ERROR — {r['error']}")
elif r["days_remaining"] <= CRITICAL_THRESHOLD_DAYS:
print(f"🔴 {r['hostname']}: CRITICAL — {r['days_remaining']} days")
elif r["days_remaining"] <= WARN_THRESHOLD_DAYS:
print(f"🟡 {r['hostname']}: WARNING — {r['days_remaining']} days")
else:
print(f"✅ {r['hostname']}: OK — {r['days_remaining']} days")
if __name__ == "__main__":
main()
Sample output:
✅ example.com: OK — 82 days
🟡 api.yourapp.com: WARNING — 18 days
🔴 staging.yourapp.com: CRITICAL — 3 days
❌ mail.yourapp.com: ERROR — [Errno 111] Connection refused
For automated monitoring, you need the script to actually alert you. Python's smtplib can send email using Gmail, SendGrid, or any SMTP provider:
import smtplib
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
def send_alert_email(results: list, smtp_host: str, smtp_port: int,
username: str, password: str,
to_email: str, from_email: str):
"""Send an alert email for domains with expiry warnings."""
warnings = [r for r in results if r.get("days_remaining", 999) <= 30 or r.get("error")]
if not warnings:
return # Nothing to alert on
subject = f"SSL Certificate Alert: {len(warnings)} domain(s) need attention"
body_lines = ["The following SSL certificates need attention:\n"]
for r in warnings:
if r["error"]:
body_lines.append(f"❌ {r['hostname']}: Connection error — {r['error']}")
else:
body_lines.append(f"⚠️ {r['hostname']}: Expires in {r['days_remaining']} days ({r['expires_at'][:10]})")
body = "\n".join(body_lines)
msg = MIMEMultipart()
msg["From"] = from_email
msg["To"] = to_email
msg["Subject"] = subject
msg.attach(MIMEText(body, "plain"))
with smtplib.SMTP_SSL(smtp_host, smtp_port) as server:
server.login(username, password)
server.sendmail(from_email, to_email, msg.as_string())
print(f"Alert sent to {to_email} for {len(warnings)} domain(s)")
Once your script works interactively, schedule it to run daily. On Linux/macOS:
# Run SSL check every day at 9am
0 9 * * * /usr/bin/python3 /home/user/ssl_monitor.py >> /var/log/ssl_monitor.log 2>&1
On Heroku Scheduler:
python ssl_monitor.py
Or as a GitHub Actions scheduled workflow:
name: SSL Certificate Check
on:
schedule:
- cron: '0 9 * * *' # Daily at 9am UTC
workflow_dispatch:
jobs:
check-ssl:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Run SSL check
run: python ssl_monitor.py
env:
SMTP_PASSWORD: ${{ secrets.SMTP_PASSWORD }}
Checking *.example.com directly will fail. Instead, check a specific subdomain that uses the wildcard cert (e.g., www.example.com or api.example.com). The expiry date on the wildcard cert is the same regardless of which subdomain you check.
By default, Python's SSL context rejects self-signed certificates. To check one without verifying the chain:
context = ssl.create_default_context()
context.check_hostname = False
context.verify_mode = ssl.CERT_NONE # Only for self-signed/internal certs
This returns the certificate data without raising a verification error. Use with caution — CERT_NONE disables all validation.
For HTTPS services on non-standard ports (e.g., 8443), pass the port explicitly:
get_ssl_expiry("internal-api.company.com", port=8443)
The 10-second timeout in the examples covers most cases, but if you're checking many domains from a slow network, consider using concurrent.futures.ThreadPoolExecutor for parallel checks:
from concurrent.futures import ThreadPoolExecutor, as_completed
with ThreadPoolExecutor(max_workers=10) as executor:
futures = {executor.submit(get_ssl_expiry, domain): domain for domain in DOMAINS}
results = [future.result() for future in as_completed(futures)]
A custom Python script handles basic monitoring, but there are gaps when you're managing more than a handful of domains:
For a few domains, the DIY approach is fine. For production infrastructure with many domains, a dedicated monitoring service handles the failure modes, retries, and alerting that a script can't.
SSLWatch monitors your SSL certificates and sends alerts at 30, 14, 7, and 1 day before expiry. No script to maintain, no cron job to monitor. Add your domains and get notified before anything expires.
Join the waitlist → Check a cert now (free)