DEV Community

Cover image for I Audited 500 WHOIS API Endpoints - Here's What I Learned
Onizuka
Onizuka

Posted on • Edited on

I Audited 500 WHOIS API Endpoints - Here's What I Learned

The bug that started this

Here's roughly what production was running. Don't write this code.

import re, socket, datetime

def get_expiry(domain):
    with socket.create_connection(("whois.verisign-grs.com", 43), timeout=8) as s:
        s.sendall(f"{domain}\r\n".encode())
        raw = b""
        while chunk := s.recv(4096):
            raw += chunk
    text = raw.decode(errors="replace")
    m = re.search(r"Expir\w* Date:\s*(\S+)", text)
    return datetime.datetime.fromisoformat(m.group(1))
Enter fullscreen mode Exit fullscreen mode

Every WHOIS tutorial tells you some version of "just regex the expiry date." That advice is dangerous. WHOIS has no schema. RFC 3912 basically says "return some text," and every registry interpreted that as a creative writing prompt. GDPR then redacted half the fields. Thin registries like .com return a stub and point you at the registrar's server, which returns a different stub with different dates.

So I built an auditor. 500 live domains, mixed registrars, TLDs from .com to .de to .io to .xyz. Raw port-43 queries, plus a few public APIs for comparison.

The audit setup

import socket

def whois_query(domain, server, port=43, timeout=8):
    with socket.create_connection((server, port), timeout=timeout) as s:
        s.sendall((domain + "\r\n").encode())
        chunks = []
        while True:
            data = s.recv(4096)
            if not data:
                break
            chunks.append(data)
    return b"".join(chunks).decode("utf-8", errors="replace")

results = {"ok": 0, "timeout": 0, "empty": 0, "garbage": 0}
for domain, server in targets:
    try:
        raw = whois_query(domain, server)
        if len(raw) < 100:
            results["empty"] += 1
        elif parse_expiry(raw) is None:
            results["garbage"] += 1
        else:
            results["ok"] += 1
    except socket.timeout:
        results["timeout"] += 1
Enter fullscreen mode Exit fullscreen mode

Baseline numbers. Median response time was 380ms, p99 was 4.2 seconds. Thirty-one queries flat-out timed out, about 6%. One server throttled me at 43 queries per minute. On port 43.

Finding 1: The failures were silent

61% of the wrong answers my auditor collected arrived as perfectly well-formed, 200-OK-equivalent responses. No error, no timeout. Just confidently incorrect data.

The Tuesday incident, explained: I was querying Verisign's thin .com WHOIS. The registry had auto-renewed the domain on its books, so the registry expiry read 2027. The registrar's WHOIS, the one that reflects what the customer actually paid for, showed the real date. I never queried it. My auditor later found that 18% of .com domains had registry and registrar expiry dates diverging by more than 24 hours.

I'd take a connection refused over a wrong timestamp, every time. At least a refused connection wakes someone up.

Finding 2: Date formats are a crime scene

I counted 23 distinct date formats across 37 TLDs. A sample:

Expiration Date: 2027-03-14T09:22:01Z     <- sane
Expiration Date: 14-Mar-2027              <- fine
Expiry Date:      2027.03.14.             <- Hungary, why
paid-till:        2027-03-14T09:22:01Z    <- .ru does its own thing
[Expires on]      2027/03/14              <- .jp, brackets included
Expiration Date: 03/14/2027               <- ambiguous on purpose
Enter fullscreen mode Exit fullscreen mode

That last one broke me. Is 03/14/2027 March 14th or the 3rd of a month that doesn't exist? My parser picked one. Silently. For one ccTLD it produced dates four months off without raising a single exception, and I only caught it because a domain I knew expired in October showed up as valid until February.

If your date parser can't fail loudly, it will fail quietly.

Finding 3: GDPR redaction, and whatever .de is doing

Huge chunks of European WHOIS now look like this:

Registrant Name: REDACTED FOR PRIVACY
Registrant Organization: REDACTED FOR PRIVACY
Registrar: 
Enter fullscreen mode Exit fullscreen mode

Note the empty registrar field. Not redacted. Empty. My parser crashed on a None where it expected a string, and honestly, I got lucky. A crash shows up in the logs.

Then there's .de. DENIC's WHOIS returns almost nothing useful unless you pass the -T dn flag, which nobody documents anywhere obvious. I burned an entire evening on that one. If you're parsing WHOIS yourself, expect every ccTLD to have one of these traps. Budget for it.

Finding 4: The subdomain takeovers were worse

While the auditor ran, I added a dangling-CNAME check, because why not. It ended up mattering more than the expiry dates.

import dns.resolver

FINGERPRINTS = {
    "github.io": "There isn't a GitHub Pages site here",
    "herokudns.com": "No such app",
    "s3.amazonaws.com": "NoSuchBucket",
    "azurewebsites.net": "404 Web Site not found",
}

def dangling_cname(host):
    try:
        answers = dns.resolver.resolve(host, "CNAME")
        target = str(answers[0].target).rstrip(".")
    except Exception:
        return None
    for service in FINGERPRINTS:
        if target.endswith(service):
            return target
    return None
Enter fullscreen mode Exit fullscreen mode

Resolve the target, then check for NXDOMAIN or the fingerprint string in the response body. (dnspython if you're doing this yourself.)

Out of 500 domains: 14 dangling CNAMEs, three of them claimable. One pointed at an unclaimed GitHub Pages repo, one at a deleted Heroku app, one at an S3 bucket that no longer existed. Any of those three could have been serving someone else's content on a legitimate company's subdomain within about ten minutes of effort. The companies had no idea. Their uptime monitors were green the whole time, because the subdomain still resolved.

I checked SSL certs too. Nine domains had certs expiring within 14 days. One had six days left, and their monitoring only watched port 443 returning 200.

What I run in production now

My take: parsing raw WHOIS yourself is fine for a weekend tool. For production monitoring, I stopped trusting it. The parsing is the easy part. Keeping up with 37 TLDs' worth of edge cases, which change without notice, is what wears you down.

I tried the usual libraries, including python-whois, and still ended up maintaining a pile of per-TLD patches. So for the monitoring pipeline I switched to a hosted API that normalizes the mess: Domain WHOIS API on RapidAPI. One call gets you WHOIS, SSL cert status, a threat score, and a subdomain takeover check, with dates already normalized to ISO. The code's on GitHub if you want to see how it handles the weird TLDs before committing.

How to use it

Grab a key from the RapidAPI listing, then:

curl -X GET "https://domain-whois-api2.p.rapidapi.com/whois?domain=example.com" \
  -H "X-RapidAPI-Key: YOUR_KEY" \
  -H "X-RapidAPI-Host: domain-whois-api2.p.rapidapi.com"
Enter fullscreen mode Exit fullscreen mode

Python version, which is closer to what my monitor actually runs:

import requests

HEADERS = {
    "X-RapidAPI-Key": "YOUR_KEY",
    "X-RapidAPI-Host": "domain-whois-api2.p.rapidapi.com",
}
BASE = "https://domain-whois-api2.p.rapidapi.com"

def check_domain(domain):
    whois = requests.get(f"{BASE}/whois", headers=HEADERS,
                         params={"domain": domain}, timeout=10).json()
    takeover = requests.get(f"{BASE}/subdomain-takeover", headers=HEADERS,
                            params={"domain": domain}, timeout=10).json()

    if whois.get("days_until_expiry", 999) < 30:
        print(f"[!] {domain} expires in {whois['days_until_expiry']} days")
    if takeover.get("vulnerable"):
        print(f"[!] takeover risk: {takeover['subdomain']} -> {takeover['service']}")
Enter fullscreen mode Exit fullscreen mode

The response comes back as clean JSON:

{
  "domain": "example.com",
  "registrar": "NameCheap, Inc.",
  "expires": "2027-03-14T09:22:01Z",
  "days_until_expiry": 412,
  "ssl": { "issuer": "Let's Encrypt", "days_until_expiry": 61 },
  "threat_score": 12,
  "takeover_risk": false
}
Enter fullscreen mode Exit fullscreen mode

Check the GitHub repo for the full route list. Two things I'd still change: I keep raw port-43 queries in my audit tooling because I want to see the garbage sometimes, and I alert at 30 days out, not 7, because registrars do weird things in that final week.

The one rule

So the one rule: treat WHOIS data like user input. It's hostile by default and validated by no one. Validate the date, cross-check registry against registrar, and fail loudly when a field is missing instead of defaulting to "probably fine." My original monitor did the opposite of all three, and it cost a client eleven days of downtime they didn't know they were having.

I'm curious how other people got burned here. Which TLD has broken your parser the hardest, and what did the response actually look like? Paste the ugliest WHOIS blob you've ever received in the comments. And if you want a quick reality check, run your own domain through the Domain WHOIS API and tell me what threat score it gives you. Mine came back higher than I expected, and I'm still not sure how I feel about that.

What's the one IP signal you always forget to check?

Top comments (0)