Security Engineering

Subdomain Discovery API: How to Map Any Domain's Attack Surface Programmatically

Find forgotten staging servers, exposed admin panels, and shadow IT — without CLI tools — using a REST API that returns structured JSON ready for your pipeline.

May 7, 202614 min readSecurity · DNS · Python · Attack Surface

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.

01

Forgotten environments

staging.company.comdev.company.comold.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.

02

Exposed admin panels

admin.company.comcpanel.company.comjenkins.company.com — administration interfaces directly reachable from the public internet, frequently without IP restriction.

03

Undocumented APIs

api-v1.company.comapi-internal.company.com — legacy endpoints left running after a migration, sometimes without updated authentication.

04

Shadow IT

hubspot.company.commailchimp.company.comlanding.company.com — SaaS services connected under the corporate domain by individual teams, outside of IT visibility.

05

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.

curlShell
curl -X GET "https://whoisjson.com/api/v1/subdomains?domain=example.com" \
  -H "Authorization: TOKEN=YOUR_API_KEY"

Example JSON response:

response.jsonJSON
{
  "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"
    }
  ]
}
Honest limitations.  Brute-force DNS only finds subdomains that appear in the wordlist. Deeply obscure or randomly generated names will not be returned. The API does active DNS resolution — it does not consult Certificate Transparency logs. For maximum coverage, combine both approaches. The API does not perform port scanning or service fingerprinting.

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.

scan_subdomains.pyPython
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}")
Edge cases to handle.  A wildcard-detected response is still useful — check the 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.

ssl_audit.pyPython
# 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:

  1. Filter all subdomains with type: "CNAME"  from the scan result.
  2. For each CNAME, attempt to resolve the target  hostname (the value the CNAME points to).
  3. If the target does not resolve (NXDOMAIN or empty response), the record is dangling — flag it as a takeover candidate.
  4. Cross-reference the target suffix against a fingerprint list of known vulnerable providers.
takeover_scan.pyPython
# 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

subdomain_diff.pyPython
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

.github/workflows/subdomain-audit.ymlYAML
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.comeu.competitor.comap.competitor.com — regional deployments signal where a competitor is investing in capacity before they announce market expansion.

Products in development

beta.competitor.compreview.competitor.comlabs.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.

Legal note.  Subdomain enumeration reads public DNS data — no systems are accessed, no credentials are tested. It is equivalent to reading a company's public DNS zone. Scope your use to passive reconnaissance: never scan subdomains outside a formal pentest engagement scope or bug bounty programme.

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.

subdomain_audit.pyPython
# 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

Subdomain API

Full endpoint reference, parameters, and interactive docs.

View Subdomain API
Subdomain Discovery API

Map Any Domain's Attack Surface in Seconds

800+ patterns, automatic wildcard filtering, structured JSON — no CLI tools, no shell dependency.

800+ subdomain patternsAutomatic wildcard detectionA and CNAME records with IPs1,000 free requests/month

Get Started Free

No credit card. 1,000 requests/month, all endpoints included.

Get Free API Key

Scale Up

From Pro to Atlas — match your plan to your scan volume.

Compare Plans