Investigate a Suspicious Domain: Historical DNS and WHOIS
A domain can look harmless today while its history tells a different story. A website might have changed hosting providers several times. Its nameservers might have moved to a new DNS provider. Its registration details
A domain can look harmless today while its history tells a different story.
A website might have changed hosting providers several times. Its nameservers might have moved to a new DNS provider. Its registration details might have changed, or its DNS records might show connections to infrastructure that deserves further investigation.
These changes do not automatically mean a domain is malicious. However, they can provide useful clues when investigating phishing websites, suspicious redirects, typosquatting, and other domain-related security concerns.
In this guide, we will investigate a domain using historical DNS and WHOIS records. We will also build a Python script that collects the available data, extracts useful timestamps, and creates a timeline for further analysis.
We will use the WhoisFreaks Historical DNS API and Historical WHOIS API for the examples.
What You Will Learn
By the end of this tutorial, you will know how to:
- Understand the difference between historical DNS and historical WHOIS.
- Retrieve historical records through APIs.
- Build a Python workflow for collecting domain history.
- Create a timeline from DNS snapshots and WHOIS dates.
- Interpret infrastructure changes without treating every change as evidence of malicious activity.
- Save the collected data for further investigation.
1. Why Historical Domain Data Matters
A current DNS lookup tells you how a domain resolves now. A current WHOIS lookup provides registration information available at the time of the query.
Neither necessarily explains what happened in the past.
Imagine you are investigating a domain reported in a phishing email. Its current IP address points to a mainstream hosting provider. That alone does not tell you whether the domain used different infrastructure last week.
Historical records can help answer questions such as:
- Which IP addresses has the domain pointed to?
- When did its nameservers change?
- Did its mail-related DNS records change?
- What registration dates and registrar information appear in historical WHOIS records?
- Do several infrastructure changes occur around the same time?
- Which observations should be checked against other security sources?
The objective is to build a timeline of observable changes, not to label a domain malicious based on a single record.
2. Understand Historical DNS and WHOIS
Historical DNS and historical WHOIS provide different kinds of evidence. They are more useful when analyzed together.
Historical DNS records
DNS records describe how a domain is configured to resolve and provide different services.
Common record types include:
| Record | What it tells you |
|---|---|
| A | IPv4 address associated with a hostname |
| AAAA | IPv6 address associated with a hostname |
| NS | Authoritative nameservers for a domain |
| MX | Mail servers responsible for receiving email |
| CNAME | Alias pointing to another hostname |
| TXT | Text data used for verification, email authentication, and other purposes |
| SOA | Information about the DNS zone, including its primary nameserver and administrative timing parameters |
Historical DNS data can help reveal changes in hosting, nameservers, mail configuration, and other infrastructure.
For example, if a domain points to one IP address in an earlier snapshot and another IP address in a later snapshot, that is an observable infrastructure change. It does not, by itself, establish why the change happened.
Historical WHOIS records
WHOIS records provide information about domain registration. Depending on the domain, registry, registrar, privacy settings, and available historical data, records may include:
- Domain creation date
- Registration update date
- Expiration date
- Registrar information
- Nameservers
- Domain status
- Registrant or administrative information, when available
Historical WHOIS records can help you compare registration information across different observation dates.
Keep one distinction in mind: a WHOIS record's query timestamp is not necessarily the date when a registration change occurred. Likewise, a domain's reported update date does not tell you exactly what changed.
Why use both?
Historical DNS helps you understand infrastructure. Historical WHOIS helps you understand the registration record.
Combining them can make an investigation more useful, especially when multiple observations support the same timeline.
3. Prepare Your API Access
For this tutorial, you need:
- Python 3.10 or later
- A WhoisFreaks API key with access to the relevant historical endpoints
- A terminal or command prompt
- An internet connection
You can review the provider's API documentation before running the examples. Check your plan's access and request limits because historical endpoint allowances vary.
Store your API key securely
Avoid putting API keys directly into source code or publishing them in a repository.
On Linux or macOS, set the key in your current terminal session:
read -rsp "Enter your WhoisFreaks API key: " WHOISFREAKS_API_KEY
echo
export WHOISFREAKS_API_KEY
The key will not be displayed while you type it.
You can check that the environment variable exists without printing its value:
test -n "$WHOISFREAKS_API_KEY" && echo "API key is set"
Keep the key private. Do not include it in screenshots, logs, or public code examples.
4. Retrieve Historical DNS Records
The Historical DNS endpoint used in this tutorial is:
https://api.whoisfreaks.com/v2.0/dns/historical
A request can include the API key, domain name, record type, and page number.
For example, this command requests historical DNS data for example.com:
curl -G "https://api.whoisfreaks.com/v2.0/dns/historical" \
--data-urlencode "apiKey=$WHOISFREAKS_API_KEY" \
--data-urlencode "domainName=example.com" \
--data-urlencode "type=all" \
--data-urlencode "page=1"
Replace example.com with the domain you are authorized to investigate.
The response can include fields such as:
totalRecordstotalPagescurrentPagehistoricalDnsRecords
Each historical snapshot can contain a query timestamp, the domain name, DNS types, and associated DNS records.
The totalPages field matters. A response containing one page is not necessarily the complete historical dataset.
If the API returns multiple pages, retrieve them according to the response metadata and your plan's rate limits.
Respect rate limits
Do not assume that a short fixed delay is sufficient for every plan.
Check the API response headers, especially:
X-RateLimit-Allowed-RequestsX-RateLimit-Remaining-RequestsX-RateLimit-Remaining-Time
The remaining-time header is expressed in nanoseconds in the responses described by the provider's rate-limit documentation. Convert it to seconds before using it as a delay.
If you receive HTTP 429, wait for the indicated reset period when available, then retry according to the API's guidance. Avoid repeatedly sending requests while the limit is exhausted.
5. Retrieve Historical WHOIS Records
The Historical WHOIS endpoint used here is:
https://api.whoisfreaks.com/v2.0/whois/history
You can request historical WHOIS data using:
curl -G "https://api.whoisfreaks.com/v2.0/whois/history" \
--data-urlencode "apiKey=$WHOISFREAKS_API_KEY" \
--data-urlencode "domainName=example.com" \
--data-urlencode "whois=historical"
The response may contain fields such as whois_domains_historical, along with registration details and observation timestamps.
Pay attention to the structure of the actual response. The provider's documentation and product examples may use different summary-field names, and the available metadata can vary. In one test of the endpoint, the response included total_records and whois_domains_historical, but did not include pagination fields.
Do not assume that every domain returns the same number of historical records or that one request always returns a complete history. Check the response and the provider's documentation for your account and endpoint.
For the script below, we make one historical WHOIS request and save the complete response returned by that request. We do not assume that WHOIS pagination is available.
6. Build a Python Domain Investigation Script
Now we can combine the two sources in a small Python workflow.
The script will:
- Request historical DNS records.
- Follow DNS pagination when the response provides page information.
- Respect available rate-limit reset headers.
- Request historical WHOIS data.
- Extract useful timestamps and IP addresses where their record fields are available.
- Create a chronological timeline.
- Save both the raw API responses and the timeline in a JSON report.
The script uses Python's standard library, so you do not need to install additional packages.
Create a file named investigate_domain.py:
import argparse
import ipaddress
import json
import os
import time
import urllib.error
import urllib.parse
import urllib.request
from datetime import datetime, timezone
DNS_URL = "https://api.whoisfreaks.com/v2.0/dns/historical"
WHOIS_URL = "https://api.whoisfreaks.com/v2.0/whois/history"
TIMEOUT_SECONDS = 30
MAX_DNS_PAGES = 20
# The next request will wait if a previous response indicates
# that the current rate-limit allowance has been exhausted.
NEXT_REQUEST_AT = 0.0
def request_json(url, params):
"""Make a GET request and return the decoded JSON response."""
global NEXT_REQUEST_AT
for attempt in range(2):
wait_seconds = NEXT_REQUEST_AT - time.monotonic()
if wait_seconds > 0:
print(
f"Rate limit reached. Waiting "
f"{wait_seconds:.1f} seconds."
)
time.sleep(wait_seconds)
query = urllib.parse.urlencode(params)
request = urllib.request.Request(
f"{url}?{query}",
headers={"Accept": "application/json"},
)
try:
with urllib.request.urlopen(
request,
timeout=TIMEOUT_SECONDS,
) as response:
body = response.read().decode("utf-8")
data = json.loads(body)
headers = response.headers
except urllib.error.HTTPError as exc:
if exc.code == 429 and attempt == 0:
reset_ns = exc.headers.get(
"X-RateLimit-Remaining-Time"
)
if reset_ns:
try:
delay = int(reset_ns) / 1_000_000_000
except ValueError:
delay = 0
if delay > 0:
print(
"HTTP 429 received. Waiting for the "
"reported reset period."
)
NEXT_REQUEST_AT = (
time.monotonic() + delay + 2
)
continue
detail = exc.read().decode(
"utf-8", errors="replace"
)
raise RuntimeError(
f"API request failed with HTTP {exc.code}: "
f"{detail[:500]}"
) from exc
except urllib.error.URLError as exc:
raise RuntimeError(
f"Network request failed: {exc.reason}"
) from exc
remaining = headers.get(
"X-RateLimit-Remaining-Requests"
)
reset_ns = headers.get(
"X-RateLimit-Remaining-Time"
)
if remaining is not None and reset_ns is not None:
try:
if int(remaining) <= 0:
reset_seconds = int(reset_ns) / 1_000_000_000
if reset_seconds > 0:
NEXT_REQUEST_AT = (
time.monotonic()
+ reset_seconds
+ 2
)
except ValueError:
pass
return data
raise RuntimeError(
"The API request could not be completed after retrying."
)
def fetch_historical_dns(api_key, domain):
"""Retrieve historical DNS pages, up to a safety limit."""
snapshots = []
for page in range(1, MAX_DNS_PAGES + 1):
print(f"Fetching historical DNS page {page}...")
data = request_json(
DNS_URL,
{
"apiKey": api_key,
"domainName": domain,
"type": "all",
"page": page,
},
)
records = data.get("historicalDnsRecords", [])
if not isinstance(records, list):
raise RuntimeError(
"Unexpected DNS response: "
"historicalDnsRecords is not a list."
)
snapshots.extend(records)
total_pages = data.get("totalPages")
if total_pages is None:
# Without pagination metadata, do not guess
# whether more pages exist.
break
try:
if page >= int(total_pages):
break
except (TypeError, ValueError):
break
else:
print(
"Warning: reached the DNS page safety limit. "
"The returned dataset may be incomplete."
)
return snapshots
def fetch_historical_whois(api_key, domain):
"""Retrieve the historical WHOIS response."""
print("Fetching historical WHOIS data...")
return request_json(
WHOIS_URL,
{
"apiKey": api_key,
"domainName": domain,
"whois": "historical",
},
)
def parse_datetime(value):
"""Parse common ISO-style timestamps and date strings."""
if value is None:
return None
if not isinstance(value, (str, int, float)):
return None
text = str(value).strip()
if not text:
return None
try:
parsed = datetime.fromisoformat(
text.replace("Z", "+00:00")
)
if parsed.tzinfo is None:
parsed = parsed.replace(tzinfo=timezone.utc)
return parsed
except ValueError:
return None
def add_event(events, value, event_type, details):
"""Add an event only when its timestamp can be parsed."""
parsed = parse_datetime(value)
if parsed is None:
return
events.append(
{
"timestamp": parsed.isoformat(),
"type": event_type,
"details": details,
}
)
def collect_ip_values(value):
"""Find valid IP addresses in common nested response fields."""
found = []
if isinstance(value, dict):
for key, item in value.items():
if key.lower() in {
"address",
"ip_address",
"ip",
} and isinstance(item, str):
try:
found.append(
str(ipaddress.ip_address(item.strip()))
)
except ValueError:
pass
else:
found.extend(collect_ip_values(item))
elif isinstance(value, list):
for item in value:
found.extend(collect_ip_values(item))
return found
def find_ip_addresses(snapshot):
"""Extract IPv4/IPv6 addresses from A and AAAA records."""
found = []
records = snapshot.get("dnsRecords", [])
if not isinstance(records, list):
return []
for record in records:
if not isinstance(record, dict):
continue
record_type = str(
record.get("dnsType", "")
).upper()
if record_type not in {"A", "AAAA"}:
continue
# DNS API responses may place address data inside
# record-type-specific nested fields.
found.extend(collect_ip_values(record))
return sorted(set(found))
def build_timeline(dns_snapshots, whois_data):
"""Create a timeline from available DNS and WHOIS fields."""
events = []
for snapshot in dns_snapshots:
if not isinstance(snapshot, dict):
continue
domain = snapshot.get("domainName", "unknown domain")
query_time = snapshot.get("queryTime")
ip_addresses = find_ip_addresses(snapshot)
add_event(
events,
query_time,
"dns_snapshot",
{
"domain": domain,
"ip_addresses": ip_addresses,
"dns_types": snapshot.get("dnsTypes", []),
},
)
whois_records = whois_data.get(
"whois_domains_historical", []
)
if isinstance(whois_records, list):
for record in whois_records:
if not isinstance(record, dict):
continue
domain = record.get("domain_name", "unknown domain")
query_time = record.get("query_time")
registrar = record.get("domain_registrar")
if isinstance(registrar, dict):
registrar = registrar.get("registrar_name")
add_event(
events,
query_time,
"whois_snapshot",
{
"domain": domain,
"registrar": registrar,
"name_servers": record.get("name_servers"),
"domain_status": record.get("domain_status"),
},
)
for field, label in [
("create_date", "reported_creation_date"),
("update_date", "reported_update_date"),
("expiry_date", "reported_expiry_date"),
]:
add_event(
events,
record.get(field),
label,
{
"domain": domain,
"source_query_time": query_time,
},
)
events.sort(key=lambda event: event["timestamp"])
return events
def main():
parser = argparse.ArgumentParser(
description=(
"Collect historical DNS and WHOIS data "
"for a domain investigation."
)
)
parser.add_argument(
"domain",
help="Domain to investigate, such as example.com",
)
parser.add_argument(
"--output",
default="domain_investigation.json",
help="Output JSON filename",
)
args = parser.parse_args()
api_key = os.environ.get("WHOISFREAKS_API_KEY")
if not api_key:
raise SystemExit(
"Error: set the WHOISFREAKS_API_KEY "
"environment variable first."
)
try:
dns_snapshots = fetch_historical_dns(
api_key, args.domain
)
whois_data = fetch_historical_whois(
api_key, args.domain
)
timeline = build_timeline(
dns_snapshots, whois_data
)
report = {
"domain": args.domain,
"generated_at": datetime.now(
timezone.utc
).isoformat(),
"dns_snapshot_count": len(dns_snapshots),
"whois_record_count": len(
whois_data.get(
"whois_domains_historical", []
)
),
"dns_snapshots": dns_snapshots,
"whois_response": whois_data,
"timeline": timeline,
}
with open(
args.output,
"w",
encoding="utf-8",
) as file:
json.dump(
report,
file,
indent=2,
ensure_ascii=False,
)
print(f"Report saved to: {args.output}")
print(f"DNS snapshots: {len(dns_snapshots)}")
print(
"WHOIS records: "
f"{report['whois_record_count']}"
)
print(f"Timeline events: {len(timeline)}")
if not timeline:
print(
"No parseable timestamps were found. "
"Inspect the saved raw response and adjust "
"the date parsing for the returned format."
)
except (RuntimeError, OSError, json.JSONDecodeError) as exc:
raise SystemExit(f"Investigation failed: {exc}")
if __name__ == "__main__":
main()
Run the script
First, make sure your API key is available in the current terminal.
Then run:
python3 investigate_domain.py example.com
To choose a different output filename:
python3 investigate_domain.py example.com \
--output example-investigation.json
The script saves the raw DNS snapshots, the raw WHOIS response, and the timeline in one JSON file.
Testing note: We tested this script using whoisfreaks.com and successfully retrieved 35 historical DNS snapshots and 410 historical WHOIS records. The script generated 1,653 timeline events, including 24 DNS events with extracted IP addresses. It also handled API rate limits by waiting for the reported reset period. Results will vary depending on the domain, available historical data, and API plan. Always review the raw records before drawing conclusions.
7. Read the Timeline
A timeline puts observations in chronological order so you can compare what was reported and when.
Results From Our Test
We ran the Python script against whoisfreaks.com using the WhoisFreaks Historical DNS and WHOIS APIs.
The script retrieved 35 DNS snapshots and 410 WHOIS records, producing 1,653 timeline events. Of the DNS snapshot events, 24 contained extracted IP addresses.
The script also successfully handled the API's rate limits, waiting approximately 62 seconds when the remaining request allowance was exhausted.
This confirms that the workflow can retrieve historical records, extract useful information, and generate a chronological JSON report for further investigation.
For example, a fictional investigation might contain these observations:
| Observation | Possible interpretation |
|---|---|
| Historical DNS snapshot shows IP A | The domain was observed pointing to IP A at that time |
| Later snapshot shows IP B | The observed infrastructure changed |
| WHOIS snapshot reports a different registrar | Registration information changed or was recorded differently |
| Nameservers differ between snapshots | DNS configuration changed |
| Several observations occur close together | The timing may justify further investigation |
These are examples of possible findings, not proof of malicious activity.
When reviewing your report, ask:
- Are the timestamps observation times or registration dates?
- Are the records complete, or were some pages unavailable?
- Does the DNS response contain multiple record types?
- Is an IP change consistent with a normal hosting migration?
- Do nameserver changes align with the observed DNS changes?
- Is the same activity visible in independent sources?
The value comes from combining evidence, understanding its limitations, and documenting the reasoning behind each conclusion.
8. Common Mistakes to Avoid
Treating every IP change as suspicious
Websites routinely change hosting providers, use content delivery networks, and rotate infrastructure. An IP change is a clue, not a verdict.
Confusing WHOIS query time with registration dates
A historical WHOIS query records what the service observed at a particular time. A creation or update date is a separate field with a different meaning.
Assuming one API response contains everything
Historical datasets may span multiple pages or contain only the records available for a particular domain and plan. Check pagination metadata and the returned record count.
Ignoring rate limits
Repeated requests can result in HTTP 429 errors. Follow the endpoint's rate-limit headers and your plan's restrictions rather than sending requests in a tight loop.
Treating missing data as evidence
A missing record does not prove that a domain never used a particular IP address, registrar, or nameserver. Historical coverage may be incomplete.
Publishing sensitive information
WHOIS responses may contain personal or otherwise sensitive information. Do not publish raw reports containing private registration details, API keys, or unrelated personal data. Share only what is necessary for the investigation.
9. Where WhoisFreaks Fits In
Historical domain investigations require more than a single current lookup. Researchers often need to compare DNS snapshots, review registration history, and preserve observations for further analysis.
WhoisFreaks provides domain intelligence data and APIs that can support this workflow.
Its historical DNS and WHOIS capabilities can help investigators collect available historical records and examine changes over time. The Python example in this guide shows one way to organize those records into a report that can be reviewed alongside other sources.
For a real investigation, combine historical records with relevant evidence such as passive DNS sources, threat intelligence feeds, certificate transparency data, URL analysis, and security vendor reports. Use sources appropriate to the domain and the question you are trying to answer.
Frequently Asked Questions
What is the difference between historical DNS and historical WHOIS?
Historical DNS records show how DNS configuration was observed over time. Historical WHOIS records show registration information recorded at different observation times. The two sources answer different questions and are most useful when compared.
Can historical DNS prove that a domain is malicious?
No. DNS changes can occur for legitimate reasons. Historical DNS can help identify infrastructure changes, but a maliciousness assessment requires additional evidence.
Does historical WHOIS always reveal the domain owner?
No. Privacy services, registry policies, redaction, and data availability can limit the information returned. A historical record may also contain different contact details from the current record.
Can I automate domain investigations with Python?
Yes. APIs make it possible to collect available records, normalize timestamps, and generate reports. You should still validate the response schema, handle rate limits, preserve raw data, and review findings before drawing conclusions.
Why should I save the raw API responses?
Raw responses help you verify how the timeline was generated, inspect fields that the script does not currently parse, and adapt the workflow when the response structure changes.
Conclusion
Investigating a suspicious domain is more useful when you look beyond its current configuration.
Historical DNS can reveal changes in the infrastructure associated with a domain, while historical WHOIS can provide context about its registration history. Combining these observations into a timeline makes it easier to identify patterns and decide what to investigate next.
A Python workflow can make the collection and organization of historical data more repeatable. However, the quality of the conclusion still depends on the completeness of the data, correct interpretation of timestamps, and corroboration from independent sources.
Use historical records to build a clearer picture of a domain's past, not to jump to conclusions from a single change.
Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.
