Dev.to Security πŸ” Cybersecurity πŸ‘ 0 πŸ“– 9 min read

Bulk-check domain expiry, SSL certificates and DMARC for a list of domains (Python + Node.js)

Expired domains and expired TLS certificates are among the most embarrassing outages, because they're completely predictable. If you look after more than a handful of domains (your own, clients', old campaign sites), a w

Expired domains and expired TLS certificates are among the most embarrassing outages, because they're completely predictable. If you look after more than a handful of domains (your own, clients', old campaign sites), a weekly check that says "this expires in 12 days" is worth a lot.

This post covers:

  1. A dependency-free Python check: domain expiry via RDAP, certificate expiry via ssl, and the DMARC policy via DNS-over-HTTPS.
  2. The pitfalls: ccTLDs without expiry data, subdomains vs registrable domains, and certificate errors.
  3. A hosted bulk checker (registrar, dates, DNS, SPF/DMARC, SSL, HTTPS redirect, HSTS) that you can run from cron or CI, from Python and Node.js.

Disclosure: I built the Domain WHOIS & DNS Checker Actor used in part 3. Parts 1 and 2 don't need it.

1. A minimal checker (Python standard library)

RDAP is the JSON successor of WHOIS. Registries publish registration events (including expiration) in a documented format, and rdap.org redirects you to the right registry. Certificate dates come straight from the TLS handshake. The DMARC record is a TXT record at _dmarc.<domain>, which you can read from Google's DNS-over-HTTPS JSON API without installing a DNS library.

import json
import socket
import ssl
import sys
import urllib.request
from datetime import datetime, timezone

UA = {"User-Agent": "domain-check script", "Accept": "application/json"}

def get_json(url):
    req = urllib.request.Request(url, headers=UA)
    with urllib.request.urlopen(req, timeout=20) as r:
        return json.load(r)

def ssl_expiry(host):
    """Days until the TLS certificate of host:443 expires (verification on)."""
    ctx = ssl.create_default_context()
    with socket.create_connection((host, 443), timeout=10) as sock:
        with ctx.wrap_socket(sock, server_hostname=host) as tls:
            not_after = tls.getpeercert()["notAfter"]   # e.g. 'Jan 27 23:59:59 2027 GMT'
    expires = datetime.fromtimestamp(ssl.cert_time_to_seconds(not_after), timezone.utc)
    return (expires - datetime.now(timezone.utc)).days

def domain_expiry(domain):
    """Registration expiry via RDAP (the JSON successor of WHOIS). rdap.org redirects
    to the right registry; many ccTLDs (e.g. .de) don't publish an expiry date."""
    data = get_json(f"https://rdap.org/domain/{domain}")
    for event in data.get("events", []):
        if event.get("eventAction") == "expiration":
            expires = datetime.fromisoformat(event["eventDate"].replace("Z", "+00:00"))
            return (expires - datetime.now(timezone.utc)).days
    return None

def dmarc_policy(domain):
    """Read the _dmarc TXT record via DNS-over-HTTPS (no dnspython needed)."""
    data = get_json(f"https://dns.google/resolve?name=_dmarc.{domain}&type=TXT")
    for answer in data.get("Answer", []):
        txt = answer["data"].strip('"').replace('" "', "")
        if txt.lower().startswith("v=dmarc1"):
            tags = dict(t.strip().split("=", 1) for t in txt.split(";") if "=" in t)
            return tags.get("p")
    return None

if __name__ == "__main__":
    for domain in sys.argv[1:] or ["github.com", "example.com"]:
        row = {"domain": domain}
        for key, check in (("domainDays", domain_expiry), ("sslDays", ssl_expiry), ("dmarc", dmarc_policy)):
            try:
                row[key] = check(domain)
            except Exception as exc:              # one failing check shouldn't hide the others
                row[key] = f"error: {exc}"
        print(row)

Output (03.11.2026):

$ python domain_stdlib.py github.com example.com tchibo.de expired.badssl.com
{'domain': 'github.com', 'domainDays': 706, 'sslDays': 85, 'dmarc': 'quarantine'}
{'domain': 'example.com', 'domainDays': 282, 'sslDays': 52, 'dmarc': 'reject'}
{'domain': 'tchibo.de', 'domainDays': 'error: HTTP Error 404: Not Found', 'sslDays': 45, 'dmarc': 'none'}
{'domain': 'expired.badssl.com', 'domainDays': 'error: HTTP Error 404: Not Found', 'sslDays': 'error: [SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed: certificate has expired (_ssl.c:1029)', 'dmarc': None}

The .com domains work. The other two rows are what part 2 is about.

2. Pitfalls

  • Not every TLD has RDAP, and not every RDAP server has dates. rdap.org returns 404 for .de, because DENIC doesn't run a public RDAP service with registration data. Its public WHOIS only tells you whether a domain is registered, with no registrar, no creation date and no expiry date. Several other ccTLDs (for example .at, .eu, .io, .it, .es, .co) either have no RDAP service in the IANA bootstrap file or don't publish expiry dates. For those, you can at least check that the domain still resolves (has NS records). For the actual renewal date, ask your registrar, because .de domains renew automatically as long as the contract runs.
  • Subdomains aren't registered domains. expired.badssl.com has no registration of its own. You have to look up badssl.com. Getting the registrable domain right needs the Public Suffix List (shop.example.co.uk β†’ example.co.uk, not co.uk).
  • An invalid certificate is a result, not a crash. With verification on, wrap_socket raises an exception for expired, self-signed or wrong-host certificates. That's what you want to report. If you also want the expiry date of a broken certificate, you need a second, non-verifying connection, and you then have to parse the binary certificate yourself.
  • Check the email side too. A missing SPF record, a missing DMARC record, or p=none (monitoring only) makes domain spoofing easier. These are the same TXT lookups, so they cost almost nothing.
  • Rate limits and terms. Registries throttle RDAP and WHOIS. Spread out bulk lookups and cache results, because expiry dates change at most once a year. WHOIS/RDAP terms (DENIC's, for example) allow lookups for technical and administrative purposes, not for building marketing lists.

3. Hosted option: Domain WHOIS & DNS Checker on Apify

The Domain WHOIS & DNS Checker takes domains, subdomains or full URLs and returns one record per input. Each record includes:

  • Registrar, creation/update/expiry dates, status, name servers and DNSSEC from the official RDAP server
  • A/AAAA/MX/NS/TXT/CAA records (via DNS-over-HTTPS)
  • SPF and DMARC policy
  • TLS certificate validity, issuer, days left and TLS version
  • HTTP status, HTTPS redirect, HSTS and response time
  • A plain-English issues list

It resolves subdomains to the registrable domain, falls back to "does it resolve?" for TLDs without RDAP data, and never collects registrant contact data.

Set APIFY_TOKEN from a free Apify account (Console β†’ Settings β†’ API & Integrations).

Python (pip install apify-client)

"""Check a list of domains for expiry, DNS, SPF/DMARC and SSL problems.

Actor: https://apify.com/paperlinenstudio/domain-whois-dns-checker
Usage: APIFY_TOKEN=... python domain_expiry_check.py example.com github.com
"""
import os
import sys

from apify_client import ApifyClient

domains = sys.argv[1:] or ["github.com", "tchibo.de", "expired.badssl.com"]
client = ApifyClient(os.environ["APIFY_TOKEN"])

run = client.actor("paperlinenstudio/domain-whois-dns-checker").call(
    run_input={
        "domains": domains,
        "expiryWarningDays": 30,   # flag domains/certificates expiring within 30 days
    }
)
rows = list(client.dataset(run.default_dataset_id).iterate_items())

for r in rows:
    ssl = r.get("ssl") or {}
    mail = r.get("emailSecurity") or {}
    print(
        f"{r['host']:<28} registered={r.get('registered')}  "
        f"domain expires in {r.get('daysUntilExpiry')} d  "
        f"SSL valid={ssl.get('valid')} ({ssl.get('daysUntilExpiry')} d)  "
        f"DMARC={mail.get('dmarcPolicy')}"
    )
    for issue in r["issues"]:
        print(f"    ! {issue}")

sys.exit(1 if any(r["issues"] for r in rows) else 0)  # handy for a cron/CI monitor

Output (03.11.2026):

$ python domain_expiry_check.py github.com tchibo.de expired.badssl.com
github.com                   registered=True  domain expires in 706 d  SSL valid=True (85 d)  DMARC=quarantine
expired.badssl.com           registered=True  domain expires in 155 d  SSL valid=False (None d)  DMARC=None
    ! No SPF record
    ! No DMARC record
    ! SSL certificate invalid: certificate has expired
tchibo.de                    registered=True  domain expires in None d  SSL valid=True (45 d)  DMARC=none
    ! DMARC policy is p=none (monitoring only)
$ echo $?
1

Compare this with part 1. expired.badssl.com now gets the expiry date of badssl.com (155 days), and tchibo.de is confirmed as registered even though .de has no expiry date. Because the script exits with 1 when there are issues, cron or CI can alert on it.

Node.js (npm i apify-client)

// Check domains for expiry, DNS, SPF/DMARC and SSL problems.
// Exits with code 1 when any domain has an issue, so it works as a cron/CI monitor.
// Usage: APIFY_TOKEN=... node domain_expiry_check.mjs example.com github.com
import { ApifyClient } from 'apify-client';

const domains = process.argv.slice(2);
const client = new ApifyClient({ token: process.env.APIFY_TOKEN });

const run = await client.actor('paperlinenstudio/domain-whois-dns-checker').call({
    domains: domains.length ? domains : ['github.com', 'tchibo.de', 'expired.badssl.com'],
    expiryWarningDays: 30,
});

const { items } = await client.dataset(run.defaultDatasetId).listItems();
for (const r of items) {
    console.log(
        `${r.host.padEnd(28)} registered=${r.registered}  domain expires in ${r.daysUntilExpiry} d  `
        + `SSL valid=${r.ssl?.valid} (${r.ssl?.daysUntilExpiry} d)  DMARC=${r.emailSecurity?.dmarcPolicy}`,
    );
    for (const issue of r.issues) console.log(`    ! ${issue}`);
}
process.exitCode = items.some((r) => r.issues.length) ? 1 : 0;
$ node domain_expiry_check.mjs github.com example.com
github.com                   registered=true  domain expires in 706 d  SSL valid=true (85 d)  DMARC=quarantine
example.com                  registered=true  domain expires in 282 d  SSL valid=true (52 d)  DMARC=reject

A single record (real, from 03.11.2026; long DNS lists shortened):

{
  "input": "github.com",
  "host": "github.com",
  "domain": "github.com",
  "tld": "com",
  "rdapServer": "https://rdap.verisign.com/com/v1/",
  "registered": true,
  "registrar": "MarkMonitor Inc.",
  "registrarIanaId": "292",
  "createdAt": "2007-10-09T18:20:50+00:00",
  "updatedAt": "2026-09-07T09:22:52+00:00",
  "expiresAt": "2028-10-09T18:20:50+00:00",
  "domainStatus": [
    "client delete prohibited",
    "client transfer prohibited",
    "client update prohibited"
  ],
  "nameservers": [
    "dns1.p08.nsone.net",
    "dns2.p08.nsone.net",
    "…"
  ],
  "dnssec": false,
  "domainAgeDays": 6965,
  "daysUntilExpiry": 706,
  "dns": {
    "A": [
      "140.82.114.3"
    ],
    "MX": [
      {
        "priority": 0,
        "host": "github-com.mail.protection.outlook.com"
      }
    ],
    "NS": [
      "… (8 entries)"
    ],
    "TXT": [
      "…"
    ],
    "CAA": [
      "…"
    ]
  },
  "dnsResolves": true,
  "registeredSource": "rdap",
  "emailSecurity": {
    "spf": "v=spf1 ip4:192.30.252.0/22 include:spf.protection.outlook.com … ~all",
    "spfRecordCount": 1,
    "dmarc": "v=DMARC1; p=quarantine; sp=reject; pct=100; rua=mailto:[email protected]; ruf=mailto:[email protected]; fo=1",
    "dmarcPolicy": "quarantine",
    "hasMx": true
  },
  "ssl": {
    "host": "github.com",
    "valid": true,
    "issuer": "Sectigo Limited",
    "subject": "github.com",
    "validFrom": "2026-10-30T00:00:00+00:00",
    "validTo": "2027-01-27T23:59:59+00:00",
    "daysUntilExpiry": 85,
    "subjectAltNames": [
      "github.com",
      "www.github.com"
    ],
    "tlsVersion": "TLSv1.3",
    "error": null
  },
  "http": {
    "url": "https://github.com/",
    "statusCode": 200,
    "finalUrl": "https://github.com/",
    "redirectsToHttps": true,
    "server": "github.com",
    "hsts": true,
    "responseTimeMs": 95,
    "error": null
  },
  "issues": [],
  "errors": [],
  "checkedAt": "2026-11-03T13:25:26+00:00"
}

Run it weekly

The easiest option is an Apify schedule on a saved task, plus a webhook or one of the Slack/email integrations to forward the results. If you'd rather use your own server, cron and the exit code are enough. Keep the token in a file only you can read, not in the crontab itself:

# ~/.apify_env contains: export APIFY_TOKEN=your_token_here   (chmod 600)
# Every Monday 07:00; cron mails the output only when the check fails
0 7 * * 1  . "$HOME/.apify_env" && cd "$HOME/domains" && python3 domain_expiry_check.py $(cat domains.txt) > last.log 2>&1 || cat last.log

In CI, store the token as a masked secret variable in your CI system and pass it to the job as the environment variable APIFY_TOKEN. The scripts only read os.environ["APIFY_TOKEN"] / process.env.APIFY_TOKEN.

Inputs you'll probably change

{
  "domains": ["example.com", "shop.example.de", "https://www.example.co.uk/page"],
  "expiryWarningDays": 45,
  "dnsRecordTypes": ["A", "MX", "TXT"],
  "checkHttp": false,
  "maxConcurrency": 10
}

expiryWarningDays adds an issue when the domain expires within that many days. Certificates are flagged at that value or 14 days, whichever is lower, because 90-day certificates renew automatically about 30 days before they expire. Every check (checkWhois, checkDns, checkEmailSecurity, checkSsl, checkHttp) can be switched off for faster runs.

Wrap-up

  • A few .com/.net/.org domains: the ~50-line RDAP + ssl script is all you need. Treat errors as results.
  • Mixed TLDs, subdomains, and SPF/DMARC/HTTPS on top, as a scheduled report: Domain WHOIS & DNS Checker.

Feedback and feature requests are welcome, either in the comments or on the Actor's Issues tab.

πŸ“° Read the original article on Dev.to Security

Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes β€” full credit and traffic to the original publisher.