DEV Community

Datawright Labs
Datawright Labs

Posted on

Check SSL certificate and domain expiry for a list of domains in Python (no WHOIS scraping, 2026)

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

  1. Normalize the input. Accept bare domains, full URLs and host:port, and drop duplicates.
  2. 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.
  3. Read the certificate with getpeercert() and compute days until notAfter.
  4. 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 the expiration event.
  5. 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']}")
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

A few things worth noticing:

  • The verification error is the diagnosis. SSLCertVerificationError.verify_message says "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 .org domains with urllib, while curl worked fine. Sending any explicit User-Agent header 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, .org and .dev, but turns shop.example.co.uk into co.uk. Use the tldextract package (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, not invalid.
  • 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)