Introduction
The external attack surface of an organisation is not its main domain — it is the full set of its subdomains, a significant portion of which is forgotten, unmaintained, or unknown to the security team. A staging server provisioned two years ago and never decommissioned. An admin panel exposed without a VPN requirement. A CNAME still pointing to a Heroku app that no longer exists.
CLI tools like subfinder and amass are powerful for one-off reconnaissance. But they produce unstructured text, require a local installation, and cannot be called from an application, a CI/CD step, or a monitoring dashboard. A REST API changes that entirely: one HTTPS request, structured JSON back, zero shell dependency.
This guide covers how the WhoisJSON subdomain discovery API works, what it finds in practice, and how to build production-ready tooling around it — including SSL coverage auditing, subdomain takeover detection, and CI/CD pipeline integration. All code examples are in Python. For attack surface management at scale, subdomain enumeration is typically the first step.
What Subdomain Discovery Actually Finds
Before writing a line of code, it is worth being precise about what a subdomain scan surfaces. In practice, results fall into five categories.
Forgotten environments
staging.company.com, dev.company.com, old.company.com — servers that were provisioned for a release and never taken down. They often run unpatched software, have debug mode active, and use development credentials.
Exposed admin panels
admin.company.com, cpanel.company.com, jenkins.company.com — administration interfaces directly reachable from the public internet, frequently without IP restriction.
Undocumented APIs
api-v1.company.com, api-internal.company.com — legacy endpoints left running after a migration, sometimes without updated authentication.
Shadow IT
hubspot.company.com, mailchimp.company.com, landing.company.com — SaaS services connected under the corporate domain by individual teams, outside of IT visibility.
Subdomain takeover candidates
CNAME records pointing to decommissioned external services — a deleted S3 bucket, a removed Heroku app, a cancelled Fastly service. Anyone can claim that external resource and serve content under your domain.
Categories 1–4 map directly to SSL certificate coverage gaps: a subdomain that is live but has no valid certificate, or whose certificate expired six months ago, is a strong indicator that no one is actively managing it.
How the WhoisJSON Subdomain Discovery API Works
The GET /api/v1/subdomains endpoint performs parallel DNS brute-force against a curated wordlist of 800+ common subdomain patterns. For each candidate, it issues a DNS query and records whether it resolves to an A record (IPv4 address) or a CNAME (canonical alias).
Wildcard detection and filtering
Some DNS zones are configured with a wildcard record — *.company.com resolves to the same IP regardless of the subdomain name. Without wildcard detection, every candidate in the wordlist would appear as a "found" subdomain, producing thousands of false positives.
The API detects wildcard zones automatically before the main scan. If a wildcard is detected, the resolved IP is recorded and used to filter results: any candidate that resolves to the wildcard IP is excluded. Only subdomains that resolve to a distinct address — one that is not the wildcard catch-all — are returned.
Response structure
Authentication is via the Authorization: TOKEN=YOUR_API_KEY header. The single required parameter is domain. An optional format parameter accepts json (default) or xml.
curl -X GET "https://whoisjson.com/api/v1/subdomains?domain=example.com" \
-H "Authorization: TOKEN=YOUR_API_KEY"
Example JSON response:
{
"domain": "example.com",
"wildcard_detected": false,
"wildcard_ip": [],
"total_found": 3,
"scan_time_ms": 845,
"subdomains": [
{
"subdomain": "api.example.com",
"type": "A",
"ips": ["93.184.216.34"],
"status": "active"
},
{
"subdomain": "mail.example.com",
"type": "CNAME",
"ips": [],
"status": "active"
},
{
"subdomain": "staging.example.com",
"type": "A",
"ips": ["203.0.113.42"],
"status": "active"
}
]
}
Basic Usage: Single Domain Scan
A minimal Python function that scans a domain and returns the list of discovered subdomains, with timeout handling and a clean retry on transient errors.
import requests
import time
API_KEY = "YOUR_API_KEY"
BASE_URL = "https://whoisjson.com/api/v1/subdomains"
HEADERS = {"Authorization": f"TOKEN={API_KEY}"}
def scan_subdomains(domain: str, retries: int = 2) -> dict:
"""
Scan a domain for active subdomains.
Returns the full API response dict.
Raises on unrecoverable errors.
"""
for attempt in range(retries + 1):
try:
resp = requests.get(
BASE_URL,
params={"domain": domain},
headers=HEADERS,
timeout=30,
)
if resp.status_code == 429:
time.sleep(60)
continue
resp.raise_for_status()
return resp.json()
except requests.Timeout:
if attempt == retries:
raise
time.sleep(2 ** attempt)
raise RuntimeError(f"Failed to scan {domain} after {retries + 1} attempts")
# --- Usage ---
result = scan_subdomains("example.com")
if result.get("wildcard_detected"):
print(f"⚠ Wildcard DNS detected — wildcard IPs: {result['wildcard_ip']}")
print(f"Found {result['total_found']} subdomains in {result['scan_time_ms']} ms\n")
for sub in result.get("subdomains", []):
record_type = sub["type"]
target = sub["ips"][0] if sub["ips"] else "(CNAME — no direct IP)"
print(f" {sub['subdomain']:45s} {record_type:5s} {target}")
wildcard_ip field and filter downstream. An empty subdomains array is valid: the domain exists but none of the wordlist patterns resolved. Treat it as "minimal subdomain footprint", not an error.Cross-Referencing Subdomains with SSL Coverage
Discovering subdomains is the first step. The second is knowing which of them are secured. A live subdomain running on HTTP, or with an expired certificate, is either a forgotten asset or a misconfiguration waiting to be exploited.
The pattern: scan for subdomains, then call the SSL certificate endpoint for each one and record the validity status. The result is an audit table showing the full subdomain inventory alongside its SSL posture.
# pip install aiohttp
import asyncio
import aiohttp
import json
API_KEY = "YOUR_API_KEY"
HEADERS = {"Authorization": f"TOKEN={API_KEY}"}
SSL_ENDPOINT = "https://whoisjson.com/api/v1/ssl-cert-check"
SUB_ENDPOINT = "https://whoisjson.com/api/v1/subdomains"
CONCURRENCY = 10 # stay within rate limits
async def get_subdomains(session: aiohttp.ClientSession, domain: str) -> list[dict]:
async with session.get(
SUB_ENDPOINT,
params={"domain": domain},
headers=HEADERS,
timeout=aiohttp.ClientTimeout(total=30),
) as resp:
resp.raise_for_status()
data = await resp.json()
return data.get("subdomains", [])
async def check_ssl(
session: aiohttp.ClientSession,
sem: asyncio.Semaphore,
subdomain: str,
) -> dict:
async with sem:
try:
async with session.get(
SSL_ENDPOINT,
params={"domain": subdomain},
headers=HEADERS,
timeout=aiohttp.ClientTimeout(total=15),
) as resp:
if resp.status == 400:
return {"subdomain": subdomain, "ssl_valid": False, "expires": None, "error": "no_cert"}
resp.raise_for_status()
ssl = await resp.json()
return {
"subdomain": subdomain,
"ssl_valid": ssl.get("valid", False),
"expires": ssl.get("valid_to"),
"issuer": ssl.get("issuer", {}).get("O"),
"error": None,
}
except Exception as exc:
return {"subdomain": subdomain, "ssl_valid": False, "expires": None, "error": str(exc)}
async def audit_ssl_coverage(domain: str) -> list[dict]:
sem = asyncio.Semaphore(CONCURRENCY)
async with aiohttp.ClientSession() as session:
subs = await get_subdomains(session, domain)
tasks = [check_ssl(session, sem, s["subdomain"]) for s in subs]
results = await asyncio.gather(*tasks)
# Sort: invalid/errored first, then by subdomain name
results.sort(key=lambda r: (r["ssl_valid"] is True, r["subdomain"]))
return results
if __name__ == "__main__":
domain = "example.com"
results = asyncio.run(audit_ssl_coverage(domain))
print(f"{'Subdomain':<45} {'SSL':^5} {'Expires':<26} {'Issuer'}")
print("-" * 100)
for r in results:
status = "✓" if r["ssl_valid"] else "✗"
expires = r["expires"] or r.get("error") or "—"
issuer = r.get("issuer") or "—"
print(f" {r['subdomain']:<43} {status:^5} {expires:<26} {issuer}")
Detecting Subdomain Takeover Candidates
A subdomain takeover occurs when a DNS record — most commonly a CNAME — points to an external service that no longer exists. If the target service allows unclaimed registrations (Heroku, AWS S3, GitHub Pages, Fastly, and others do), an attacker can claim it and serve arbitrary content under your domain: phishing pages, malware, credential harvesting forms.
The detection logic is straightforward:
- Filter all subdomains with
type: "CNAME"from the scan result. - For each CNAME, attempt to resolve the target hostname (the value the CNAME points to).
- If the target does not resolve (NXDOMAIN or empty response), the record is dangling — flag it as a takeover candidate.
- Cross-reference the target suffix against a fingerprint list of known vulnerable providers.
# pip install dnspython requests
import requests
import dns.resolver
import dns.exception
API_KEY = "YOUR_API_KEY"
HEADERS = {"Authorization": f"TOKEN={API_KEY}"}
# Known providers whose unclaimed resources can be taken over
VULNERABLE_PROVIDERS = [
"herokudns.com",
"amazonaws.com",
"s3.amazonaws.com",
"cloudfront.net",
"github.io",
"fastly.net",
"azurewebsites.net",
"azureedge.net",
"pantheonsite.io",
"netlify.app",
"pages.dev",
]
def resolves(hostname: str) -> bool:
"""Return True if the hostname resolves to at least one A/AAAA record."""
try:
dns.resolver.resolve(hostname, "A", lifetime=4)
return True
except (dns.resolver.NXDOMAIN, dns.resolver.NoAnswer,
dns.exception.Timeout, dns.resolver.NoNameservers):
pass
try:
dns.resolver.resolve(hostname, "AAAA", lifetime=4)
return True
except Exception:
return False
def find_takeover_candidates(domain: str) -> list[dict]:
resp = requests.get(
"https://whoisjson.com/api/v1/subdomains",
params={"domain": domain},
headers=HEADERS,
timeout=30,
)
resp.raise_for_status()
subdomains = resp.json().get("subdomains", [])
cnames = [s for s in subdomains if s["type"] == "CNAME"]
candidates = []
for sub in cnames:
# The CNAME target is stored differently depending on the API response;
# when ips is empty the subdomain itself is the dangling pointer
target = sub["subdomain"]
if not resolves(target):
known_provider = next(
(p for p in VULNERABLE_PROVIDERS if target.endswith(p)),
None,
)
candidates.append({
"subdomain": sub["subdomain"],
"cname_target": target,
"resolves": False,
"known_provider": known_provider,
"risk": "HIGH" if known_provider else "MEDIUM",
})
return candidates
if __name__ == "__main__":
domain = "example.com"
candidates = find_takeover_candidates(domain)
if not candidates:
print(f"No takeover candidates found for {domain}.")
else:
print(f"⚠ {len(candidates)} takeover candidate(s) for {domain}:\n")
for c in candidates:
print(f" [{c['risk']}] {c['subdomain']}")
print(f" CNAME target : {c['cname_target']}")
if c["known_provider"]:
print(f" Provider : {c['known_provider']}")
print()
For the authoritative list of provider fingerprints, refer to the can-i-take-over-xyz project. Combine this with DNS records lookup to verify the full record chain for each candidate.
Subdomain Discovery in a CI/CD Pipeline
Integrating a subdomain scan into a deployment pipeline creates a lightweight perimeter control: any new subdomain that appears in infrastructure gets reviewed before it goes unnoticed for months. The workflow uses a snapshot stored in the repository as the baseline.
Python: snapshot comparison
import json
import pathlib
import requests
API_KEY = "YOUR_API_KEY"
HEADERS = {"Authorization": f"TOKEN={API_KEY}"}
SNAPSHOT = pathlib.Path("subdomain_snapshot.json")
def fetch_subdomains(domain: str) -> set[str]:
resp = requests.get(
"https://whoisjson.com/api/v1/subdomains",
params={"domain": domain},
headers=HEADERS,
timeout=30,
)
resp.raise_for_status()
return {s["subdomain"] for s in resp.json().get("subdomains", [])}
def compare_snapshots(domain: str) -> dict:
current = fetch_subdomains(domain)
if SNAPSHOT.exists():
known = set(json.loads(SNAPSHOT.read_text(encoding="utf-8")))
else:
known = set()
added = sorted(current - known)
removed = sorted(known - current)
# Persist updated snapshot
SNAPSHOT.write_text(
json.dumps(sorted(current), indent=2),
encoding="utf-8",
)
return {"added": added, "removed": removed, "total": len(current)}
if __name__ == "__main__":
domain = "example.com"
diff = compare_snapshots(domain)
print(f"Total subdomains: {diff['total']}")
if diff["added"]:
print(f"\n🆕 New subdomains ({len(diff['added'])}):")
for s in diff["added"]:
print(f" + {s}")
if diff["removed"]:
print(f"\n❌ Removed subdomains ({len(diff['removed'])}):")
for s in diff["removed"]:
print(f" - {s}")
if not diff["added"] and not diff["removed"]:
print("No changes detected.")
# Exit code 1 if new subdomains detected — lets CI fail the step
if diff["added"]:
raise SystemExit(1)
GitHub Actions integration
name: Subdomain Audit
on:
schedule:
- cron: "0 6 * * 1" # every Monday at 06:00 UTC
push:
branches: [main]
jobs:
audit:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Install dependencies
run: pip install requests
- name: Run subdomain diff
env:
WHOISJSON_API_KEY: ${{ secrets.WHOISJSON_API_KEY }}
run: python subdomain_diff.py
continue-on-error: true # prevents hard CI failure; alert via Slack instead
- name: Commit updated snapshot
if: always()
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add subdomain_snapshot.json
git diff --cached --quiet || git commit -m "chore: update subdomain snapshot"
git push
The snapshot file is committed to the repository and becomes the authoritative record of known subdomains. New entries trigger a CI failure (or a Slack notification if continue-on-error: true is set), prompting a manual review before the next deployment is approved. See the domain change monitoring guide for a broader treatment of this pattern.
Competitive Intelligence Use Case
DNS data is public by design. Scanning a competitor's subdomains is entirely legal — you are reading publicly available infrastructure records, not accessing any system. The findings can be surprisingly revealing.
Tech stack signals
jenkins.competitor.com — Jenkins CI. grafana.competitor.com — Grafana observability. vault.competitor.com — HashiCorp Vault for secrets management. These subdomains reveal infrastructure choices before a competitor publishes a case study.
Geographic footprint
us.competitor.com, eu.competitor.com, ap.competitor.com — regional deployments signal where a competitor is investing in capacity before they announce market expansion.
Products in development
beta.competitor.com, preview.competitor.com, labs.competitor.com — public beta environments give advance notice of product direction.
SaaS stack
go.competitor.com (HubSpot), info.competitor.com (Marketo), tracking.competitor.com — marketing and analytics infrastructure visible through CNAME targets.
Full Production Script
The script below combines all three capabilities — subdomain discovery, SSL coverage check, and takeover detection — into a single auditor that produces a JSON report and a formatted console output. Drop it into any environment with requests and aiohttp installed.
# pip install requests aiohttp dnspython
import asyncio
import json
import sys
import time
import aiohttp
import dns.exception
import dns.resolver
import requests
API_KEY = "YOUR_API_KEY"
HEADERS = {"Authorization": f"TOKEN={API_KEY}"}
SUB_ENDPOINT = "https://whoisjson.com/api/v1/subdomains"
SSL_ENDPOINT = "https://whoisjson.com/api/v1/ssl-cert-check"
CONCURRENCY = 8
VULNERABLE_PROVIDERS = [
"herokudns.com", "amazonaws.com", "cloudfront.net",
"github.io", "fastly.net", "azurewebsites.net",
"netlify.app", "pages.dev", "pantheonsite.io",
]
# ── Step 1: Subdomain discovery ──────────────────────────────────────
def scan_subdomains(domain: str) -> dict:
resp = requests.get(
SUB_ENDPOINT, params={"domain": domain},
headers=HEADERS, timeout=30,
)
resp.raise_for_status()
return resp.json()
# ── Step 2: SSL coverage ─────────────────────────────────────────────
async def check_ssl(
session: aiohttp.ClientSession,
sem: asyncio.Semaphore,
subdomain: str,
) -> dict:
async with sem:
try:
async with session.get(
SSL_ENDPOINT, params={"domain": subdomain},
headers=HEADERS,
timeout=aiohttp.ClientTimeout(total=15),
) as resp:
if resp.status == 400:
return {"subdomain": subdomain, "ssl_valid": False,
"expires": None, "issuer": None, "error": "no_cert"}
resp.raise_for_status()
ssl = await resp.json()
return {
"subdomain": subdomain,
"ssl_valid": ssl.get("valid", False),
"expires": ssl.get("valid_to"),
"issuer": ssl.get("issuer", {}).get("O"),
"error": None,
}
except Exception as exc:
return {"subdomain": subdomain, "ssl_valid": False,
"expires": None, "issuer": None, "error": str(exc)}
async def run_ssl_audit(subdomains: list[str]) -> list[dict]:
sem = asyncio.Semaphore(CONCURRENCY)
async with aiohttp.ClientSession() as session:
return await asyncio.gather(
*[check_ssl(session, sem, s) for s in subdomains]
)
# ── Step 3: Takeover candidates ──────────────────────────────────────
def resolves(hostname: str) -> bool:
for rtype in ("A", "AAAA"):
try:
dns.resolver.resolve(hostname, rtype, lifetime=4)
return True
except Exception:
pass
return False
def find_takeover_candidates(subdomains: list[dict]) -> list[dict]:
candidates = []
for sub in subdomains:
if sub["type"] != "CNAME":
continue
target = sub["subdomain"]
if not resolves(target):
provider = next(
(p for p in VULNERABLE_PROVIDERS if target.endswith(p)), None
)
candidates.append({
"subdomain": sub["subdomain"],
"target": target,
"provider": provider,
"risk": "HIGH" if provider else "MEDIUM",
})
return candidates
# ── Step 4: Report ───────────────────────────────────────────────────
def generate_report(domain: str, scan: dict, ssl: list[dict], takeover: list[dict]) -> dict:
ssl_index = {r["subdomain"]: r for r in ssl}
entries = []
for sub in scan.get("subdomains", []):
name = sub["subdomain"]
s = ssl_index.get(name, {})
entries.append({
"subdomain": name,
"type": sub["type"],
"ips": sub.get("ips", []),
"ssl_valid": s.get("ssl_valid"),
"ssl_expiry": s.get("expires"),
"ssl_issuer": s.get("issuer"),
"ssl_error": s.get("error"),
})
return {
"domain": domain,
"scan_time_ms": scan.get("scan_time_ms"),
"wildcard_detected": scan.get("wildcard_detected"),
"total_subdomains": scan.get("total_found"),
"ssl_issues": sum(1 for e in entries if not e["ssl_valid"]),
"takeover_candidates": takeover,
"subdomains": entries,
}
# ── Main ─────────────────────────────────────────────────────────────
if __name__ == "__main__":
domain = sys.argv[1] if len(sys.argv) > 1 else "example.com"
print(f"Auditing {domain} …\n")
# 1. Discover
scan = scan_subdomains(domain)
subs = scan.get("subdomains", [])
print(f" Subdomains found : {scan['total_found']}")
if scan.get("wildcard_detected"):
print(f" ⚠ Wildcard DNS detected ({', '.join(scan['wildcard_ip'])})")
# 2. SSL
names = [s["subdomain"] for s in subs]
ssl_data = asyncio.run(run_ssl_audit(names))
issues = sum(1 for r in ssl_data if not r["ssl_valid"])
print(f" SSL issues : {issues}/{len(ssl_data)}")
# 3. Takeover
candidates = find_takeover_candidates(subs)
print(f" Takeover risks : {len(candidates)}\n")
# 4. Report
report = generate_report(domain, scan, ssl_data, candidates)
out = f"{domain.replace('.', '_')}_audit.json"
with open(out, "w", encoding="utf-8") as fh:
json.dump(report, fh, indent=2)
print(f"Report saved → {out}")
# Console summary
if candidates:
print(f"\n⚠ Takeover candidates:")
for c in candidates:
print(f" [{c['risk']}] {c['subdomain']} → {c['target']}")
if issues:
print(f"\n✗ SSL issues:")
for r in ssl_data:
if not r["ssl_valid"]:
err = r.get("error") or f"expires {r.get('expires', '?')}"
print(f" {r['subdomain']} — {err}")
Conclusion
A subdomain scan surfaces five categories of risk that are otherwise invisible: forgotten environments, exposed admin panels, undocumented APIs, shadow IT, and takeover candidates. CLI tools reach none of this from inside an application. A REST API does — in a single HTTPS request, returning structured JSON that is immediately usable in pipelines, dashboards, and automated monitors.
The natural next step is combining subdomain discovery with DNS records lookup to build a complete external DNS inventory: subdomains as the breadth layer, record-type queries as the depth layer.
Start for Free
Subdomain discovery API with 1,000 free requests/month. No credit card.
Get Your Free API Key