Two things quietly take a website down: a TLS certificate that expires, and a domain registration that lapses. Auto-renewal makes both rarer, which is exactly why they still bite. The renewal fails once, nobody is watching, and you find out from customers.
You don't need a monitoring subscription to catch either. Python's standard library can read a server's certificate, and RDAP (the JSON successor to WHOIS that registries now have to run) gives you the domain expiry date without scraping WHOIS text. This post builds one script that does both for a whole list of hosts, then looks at what a real run returns.
The approach
-
Normalize the input. Accept bare domains, full URLs and
host:port, and drop duplicates. -
Open a verified TLS connection with
ssl.create_default_context(). If the handshake fails verification, the exception already tells you why: expired, self-signed, untrusted chain or hostname mismatch. You get that diagnosis for free. -
Read the certificate with
getpeercert()and compute days untilnotAfter. -
Look up domain expiry over RDAP. IANA publishes a bootstrap file that maps each TLD to its registry's RDAP server. Load it once, then request
/domain/<name>and read theexpirationevent. - Run it in parallel with a thread pool, since nearly all the time is network wait.
The script
# Usage: python3 ssl_check.py domains.txt [warn_days]
# Checks the TLS certificate of every host (in parallel) plus domain expiry via RDAP, writes ssl_report.csv.
import csv, socket, ssl, sys, json, datetime as dt, urllib.request
from concurrent.futures import ThreadPoolExecutor
WARN = int(sys.argv[2]) if len(sys.argv) > 2 else 30
NOW = dt.datetime.now(dt.timezone.utc)
def parse_host(line):
s = line.strip().split('//')[-1].split('/')[0]
host, _, port = s.partition(':')
return host.lower(), int(port or 443)
def cert_info(host, port):
row = {'host': host, 'port': port, 'status': 'ok', 'problems': ''}
ctx = ssl.create_default_context()
try:
with socket.create_connection((host, port), timeout=8) as sock:
with ctx.wrap_socket(sock, server_hostname=host) as tls:
c = tls.getpeercert()
row['tls'] = tls.version()
except ssl.SSLCertVerificationError as e:
row.update(status='invalid', problems=e.verify_message)
return row
except Exception as e:
row.update(status='error', problems=type(e).__name__)
return row
end = dt.datetime.fromtimestamp(ssl.cert_time_to_seconds(c['notAfter']), dt.timezone.utc)
days = (end - NOW).days
issuer = dict(x[0] for x in c['issuer'])
row.update(valid_to=end.date().isoformat(), days_left=days,
issuer=issuer.get('organizationName', issuer.get('commonName')),
sans=' '.join(v for k, v in c.get('subjectAltName', []) if k == 'DNS'))
if days < WARN:
row.update(status='expiring', problems=f'Certificate expires in {days} days')
return row
RDAP = {}
def rdap_base(tld):
if not RDAP:
boot = json.load(urllib.request.urlopen('https://data.iana.org/rdap/dns.json', timeout=20))
for tlds, urls in boot['services']:
for t in tlds:
RDAP[t] = urls[0]
return RDAP.get(tld)
def domain_expiry(host):
parts = host.split('.')
domain = '.'.join(parts[-2:]) # naive: fine for .com/.org/.io, not for .co.uk
base = rdap_base(parts[-1])
if not base:
return {}
try:
req = urllib.request.Request(base.rstrip('/') + '/domain/' + domain, headers={'Accept': 'application/rdap+json', 'User-Agent': 'ssl-check/1.0'})
d = json.load(urllib.request.urlopen(req, timeout=15))
except Exception:
return {}
for ev in d.get('events', []):
if ev.get('eventAction') == 'expiration':
exp = dt.datetime.fromisoformat(ev['eventDate'].replace('Z', '+00:00'))
return {'domain_expires': exp.date().isoformat(), 'domain_days_left': (exp - NOW).days}
return {}
def check(line):
host, port = parse_host(line)
row = cert_info(host, port)
row.update(domain_expiry(host))
return row
hosts = list(dict.fromkeys(l.strip() for l in open(sys.argv[1]) if l.strip()))
rdap_base('com') # load the IANA bootstrap file once, before the threads start
with ThreadPoolExecutor(20) as pool:
rows = list(pool.map(check, hosts))
rows.sort(key=lambda r: (r['status'] == 'ok', r.get('days_left', 9999)))
cols = ['host', 'port', 'status', 'days_left', 'valid_to', 'issuer', 'tls', 'domain_expires', 'domain_days_left', 'problems', 'sans']
with open('ssl_report.csv', 'w', newline='') as f:
w = csv.DictWriter(f, cols, extrasaction='ignore'); w.writeheader(); w.writerows(rows)
for r in rows:
print(f"{r['status']:9} {r['host']:28} cert {str(r.get('days_left', '-')):>4} d domain {str(r.get('domain_days_left', '-')):>5} d {r['problems']}")
Save your hosts one per line in domains.txt and run python3 ssl_check.py domains.txt 30. The second argument is the warning window in days.
A real run
I ran it on 11 hosts: seven normal sites plus four of the deliberately broken test hosts from badssl.com. It finished in 0.6 seconds, output sorted so problems come first (October 22, 2026):
invalid expired.badssl.com cert - d domain 167 d certificate has expired
invalid self-signed.badssl.com cert - d domain 167 d self-signed certificate
invalid wrong.host.badssl.com cert - d domain 167 d Hostname mismatch, certificate is not valid for 'wrong.host.badssl.com'.
invalid untrusted-root.badssl.com cert - d domain 167 d self-signed certificate in certificate chain
ok github.com cert 38 d domain 718 d
ok example.com cert 64 d domain 295 d
ok www.wikipedia.org cert 72 d domain 82 d
ok stackoverflow.com cert 81 d domain 103 d
ok apify.com cert 86 d domain 3145 d
ok python.org cert 115 d domain 2349 d
ok crawlee.dev cert 179 d domain 625 d
A few things worth noticing:
-
The verification error is the diagnosis.
SSLCertVerificationError.verify_messagesays "certificate has expired" or "Hostname mismatch" directly, so you don't have to re-implement chain or hostname checks. - Short certificate lifetimes are normal now. GitHub's certificate had 38 days left and that's fine; CAs are moving toward much shorter validity periods. A 30-day warning window is reasonable today, but expect to tighten it as lifetimes shrink.
- Domain expiry can be closer than the certificate's. wikipedia.org's registration had 82 days left, and stackoverflow.com's 103, both sooner than some certificates on the list. Checking only certificates would miss that.
Gotchas I hit
-
Some RDAP servers reject Python's default User-Agent. My first run got no data for
.orgdomains withurllib, whilecurlworked fine. Sending any explicitUser-Agentheader fixed it. -
Old TLS versions show up as errors, not warnings. Modern OpenSSL builds refuse TLS 1.0 and 1.1 handshakes, so a server that only speaks those fails to connect. If you need to report the protocol instead of failing, use a separate context with a lower
minimum_version. -
The registrable-domain logic is naive. Taking the last two labels works for
.com,.organd.dev, but turnsshop.example.co.ukintoco.uk. Use thetldextractpackage (Public Suffix List) for real input. -
Non-standard ports may be blocked by your network. A test host on port 1011 timed out from my machine, which the script reports as
error, notinvalid. - RDAP isn't universal for country-code TLDs. Many ccTLDs aren't in the IANA bootstrap file yet; the script just leaves domain expiry blank for those.
Running it on a schedule
The script is enough for a cron job that emails you ssl_report.csv when anything isn't ok. If you'd rather not host that, I also published this as a cloud tool: SSL Certificate Checker on Apify (disclosure: I built it). It does the same checks plus SANs, chain, key type, cipher and handshake time, runs on a daily schedule with Slack or email alerts, and costs $1 per 1,000 hosts checked. Unreachable hosts are free.
Either way, the point is to have something look at the dates before they matter.
Top comments (0)