Reconciler Security / Takeover Risk

Orphaned DNS records left after service teardown

A team spins up app.example.com pointing at a load balancer or a SaaS app. Months later the underlying resource is torn down, but nobody deletes the DNS record. It sits there, quietly resolving to a name that no longer belongs to anyone, until a stranger notices and claims it first. Here is why the record outlives the service, and a script that finds every one of these before someone else does.

Security risk Python and Node.js Safe by default (dry run)
A computer monitor
Photo by Jake Walker on Unsplash
The short answer

When you delete a cloud resource or cancel a SaaS app, the DNS record that pointed at it is a separate object that nobody is forced to clean up. It keeps resolving to a name that is now unused, and on many platforms that name can be claimed by anyone, including an attacker, which puts your trusted domain in front of their content. Run a script that lists every CNAME and A/AAAA record in your zone, checks each target against your live infrastructure, and deletes or repoints the ones that no longer match anything real. Full checks, fix, and a script are below.

The problem in plain words

Say your team spins up a subdomain like staging.example.com pointing at an EC2 instance, or promo.example.com pointing at a Heroku app. That works fine while the resource is alive. But a DNS record and the resource it points at are two completely separate things, stored in two completely separate systems. Deleting the EC2 instance or closing the Heroku app does nothing to the DNS record. It just keeps answering with the old target, forever, unless someone remembers to remove it.

Nobody is forced to remember. Decommissioning a service usually means turning off billing and deleting the resource, not searching every DNS zone for anything that still points at it. So the record becomes dangling: it resolves fine at the DNS layer, but the thing on the other end is gone. On many cloud and SaaS platforms, an unused hostname or bucket name is free for the next person who asks for it. An attacker who finds your dangling record, through subdomain enumeration or by watching certificate transparency logs, can register that same name on the same platform and instantly serve their own content under your domain, often with a certificate that looks completely legitimate.

old-app.example.com CNAME old-app.herokuapp.com Heroku app deleted, name unclaimed Attacker re-registers same name Takeover old-app.example.com now serves attacker the record itself was never deleted
The cloud resource is gone, but the DNS record that pointed at it stayed behind. Whoever claims that same name on the provider now controls what your subdomain shows.

Why it happens

This is a well documented class of bug. Palo Alto Networks and DNS Made Easy both describe dangling DNS as one of the most common, and most overlooked, ways a trusted domain ends up serving someone else's content, precisely because the record and the resource are managed in different places and go stale independently.

The key insight

A DNS record is a promise that something is listening at the other end. Nothing checks that promise once it is made. The record keeps resolving whether the resource behind it is alive, deleted, or claimed by a stranger. Treat every teardown as incomplete until the matching DNS record is confirmed gone or repointed, the same way you would not consider a server decommissioned while it is still plugged into the network.

The fix, as a flow

We do not wait for a report. We pull every record in the zone, resolve what each one currently points at, and compare that against a list of infrastructure you actually run today, pulled straight from the cloud or SaaS provider's own API. Anything that does not match a live resource, and looks like a known "unclaimed" fingerprint, gets flagged and removed or repointed.

List zone records CNAME, A, AAAA Resolve each target and probe HTTP fingerprint Match live inventory? cloud API / CMDB list Delete or repoint via Cloudflare API Re-run diff zero unmatched left
The inventory diff comes first, so nothing is deleted on a guess. Only records with no matching live resource, confirmed by an unclaimed fingerprint, get removed or repointed.

How to fix it

1

Enumerate the zone and see what each host currently points at

Pull every CNAME, A, and AAAA record from your zone and check what each one currently resolves to. This is your starting list of candidates.

Terminal
enumerate.sh
dig +short CNAME app.example.com
# a hostname here is the current target, e.g. old-app.s3-website-us-east-1.amazonaws.com

dig +short A staging.example.com
# an IP here means this is an A record, cross check it against your inventory
2

Resolve the target itself, not just the record

Follow the CNAME to its own destination. An NXDOMAIN, a SERVFAIL, or a generic "bucket does not exist" or "no such app" response is the signature of a dangling record. The DNS layer itself may still say NOERROR even while the resource behind it is completely gone.

Terminal
check-target.sh
dig +short old-app.s3-website-us-east-1.amazonaws.com
dig +short some-app.herokuapp.com
# NXDOMAIN, SERVFAIL, or empty output is the signature of dangling DNS

curl -sS -o /dev/null -w "%{http_code}\n" https://app.example.com
curl -sS https://app.example.com | head -c 300
# look for platform fingerprints in the body: NoSuchBucket, "no such app",
# "There isn't a GitHub Pages site here"
3

Check whois or RDAP when the target is a third-party domain

Sometimes the CNAME points at a vendor's own domain rather than a platform default hostname. If that vendor domain itself has expired, anyone can re-register it, which is just as dangerous as an unclaimed cloud resource.

Terminal
whois-check.sh
whois oldvendor-cdn.com
# an expired registration or "no match" is a sign anyone can claim this domain
4

Cross-check every record against your live infrastructure inventory

A record can resolve perfectly fine at the DNS layer and still be orphaned, if the thing it points at is simply absent from your current asset list. Diff the zone's records against a cloud API list of running resources, an internal CMDB, or a list of active app names, so nothing slips through just because it happens to answer.

5

Delete the record, or repoint it to current infrastructure

Once a record is confirmed orphaned and no longer needed, remove it entirely. If the hostname still needs to work, repoint it at infrastructure you actually own today rather than leaving the old target in place.

DNS record
dns-records.txt
# Option A: no longer needed, delete it outright
# remove the CNAME entirely
old-app.example.com   CNAME   old-app.herokuapp.com   (delete this record)

# remove the A record for a decommissioned EC2 instance
staging.example.com   A       203.0.113.45            (delete this record)

# Option B: still needed, repoint at current, owned infrastructure
app.example.com       CNAME   app-v2.herokuapp.com    TTL: Auto
# only if app-v2 is a live app you actively own today
6

Delete or repoint through the Cloudflare API

List current records first, then delete the confirmed orphaned ones. A PATCH or PUT on the same endpoint repoints a record instead, if the hostname needs to keep working.

Terminal
cloudflare-cleanup.sh
curl -sS -X GET "https://api.cloudflare.com/client/v4/zones/{zone_id}/dns_records?type=CNAME" \
  -H "Authorization: Bearer {api_token}"

curl -sS -X DELETE "https://api.cloudflare.com/client/v4/zones/{zone_id}/dns_records/{record_id}" \
  -H "Authorization: Bearer {api_token}"
Put this in the decommission checklist

The root cause here is a missing step, not a one-time mistake. Add "remove the DNS record" as a required box to check whenever a service is decommissioned. Where the provider supports it, add a TXT ownership marker, like Azure App Service's asuid. domain verification, so nobody else can claim the hostname even if the record briefly dangles.

How to check it worked

Re-run the same lookups. A clean result either returns nothing at all, because the record is gone, or resolves to infrastructure you currently own and control.

Terminal
verify.sh
dig +short CNAME old-app.example.com
# expect: nothing (NXDOMAIN/empty) if deleted, or the new owned target if repointed

curl -sS -I https://old-app.example.com
# expect: connection fails (record gone), or a 200 from infrastructure you
# control (record repointed). It must NOT show a third-party unclaimed page.

curl -sS -X GET "https://api.cloudflare.com/client/v4/zones/{zone_id}/dns_records/{record_id}" \
  -H "Authorization: Bearer {api_token}"
# expect: 404 (record deleted), or the content field showing the updated,
# owned target

The full code

Here is a small script in each language that lists a zone's CNAME and A/AAAA records, cross-checks each target against a live infrastructure inventory and an unclaimed-provider fingerprint, and, when told to, deletes the confirmed orphaned records through the Cloudflare API. It stays in dry run by default so it only reports what it would change.

View this code on GitHub Full runnable folder with tests in the dns-fixes repo.

orphaned_records.py
"""Detect DNS records left behind after a service teardown, and optionally
repair the zone via Cloudflare.

Safe by default. Set DRY_RUN=false to let it write.

Env vars:
  DNS_DOMAIN               the zone to check, e.g. "example.com"
  CLOUDFLARE_API_TOKEN     Cloudflare API token (only needed for repair)
  CLOUDFLARE_ZONE_ID       Cloudflare zone id (only needed for repair)
  LIVE_INVENTORY           comma separated list of hostnames/IPs currently
                           provisioned, e.g. from a cloud API or CMDB
  DRY_RUN                  default "true"; set to "false" to actually write
"""
import os
import logging

logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("orphaned_records")

DNS_DOMAIN = os.environ.get("DNS_DOMAIN", "example.com")
CLOUDFLARE_API_TOKEN = os.environ.get("CLOUDFLARE_API_TOKEN", "")
CLOUDFLARE_ZONE_ID = os.environ.get("CLOUDFLARE_ZONE_ID", "")
LIVE_INVENTORY = {
    item.strip().lower() for item in os.environ.get("LIVE_INVENTORY", "").split(",") if item.strip()
}
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"

CF_API = "https://api.cloudflare.com/client/v4"

UNCLAIMED_FINGERPRINTS = {
    "s3-website-us-east-1.amazonaws.com": "NoSuchBucket",
    "herokuapp.com": "no-such-app",
    "github.io": "There isn't a GitHub Pages site here",
}


def classify_record(record, live_inventory, unclaimed_fingerprints):
    """Pure decision function. No I/O.

    record: {"type": "CNAME"|"A"|"AAAA", "name": str, "content": str}
    live_inventory: set of hostnames/IPs currently provisioned (from a
      cloud API or CMDB snapshot), already lowercased with no trailing dot.
    unclaimed_fingerprints: {provider_domain_suffix: expected_404_body_substring}
      for known dangling signatures.

    Returns one of "orphaned", "active", "needs_manual_review".
    """
    target = record["content"].rstrip(".").lower()

    if target in live_inventory:
        return "active"

    for suffix in unclaimed_fingerprints:
        if target.endswith(suffix) and target not in live_inventory:
            return "orphaned"

    return "needs_manual_review"


def list_zone_records():
    """List CNAME, A, and AAAA records in the zone via the Cloudflare API."""
    import requests

    headers = {"Authorization": f"Bearer {CLOUDFLARE_API_TOKEN}"}
    records = []
    for record_type in ("CNAME", "A", "AAAA"):
        r = requests.get(
            f"{CF_API}/zones/{CLOUDFLARE_ZONE_ID}/dns_records",
            headers=headers, params={"type": record_type, "per_page": 5000}, timeout=30,
        )
        r.raise_for_status()
        for rec in r.json().get("result", []):
            records.append({
                "id": rec["id"], "type": rec["type"], "name": rec["name"], "content": rec["content"],
            })
    return records


def delete_record(record_id):
    """Delete a single DNS record through the Cloudflare API."""
    import requests

    headers = {"Authorization": f"Bearer {CLOUDFLARE_API_TOKEN}"}
    url = f"{CF_API}/zones/{CLOUDFLARE_ZONE_ID}/dns_records/{record_id}"
    if DRY_RUN:
        log.info("[dry run] would delete record %s", record_id)
        return
    requests.delete(url, headers=headers, timeout=30).raise_for_status()
    log.info("Deleted orphaned record %s", record_id)


def run():
    if not (CLOUDFLARE_API_TOKEN and CLOUDFLARE_ZONE_ID):
        log.warning("No Cloudflare credentials set. Nothing to scan for %s.", DNS_DOMAIN)
        return

    records = list_zone_records()
    orphaned = 0
    for record in records:
        verdict = classify_record(record, LIVE_INVENTORY, UNCLAIMED_FINGERPRINTS)
        if verdict == "orphaned":
            log.info("%s -> %s is orphaned", record["name"], record["content"])
            delete_record(record["id"])
            orphaned += 1
        elif verdict == "needs_manual_review":
            log.warning("%s -> %s needs manual review", record["name"], record["content"])
        else:
            log.info("%s -> %s is active", record["name"], record["content"])

    log.info("Done. %d orphaned record(s) %s.", orphaned, "to remove" if DRY_RUN else "removed")


if __name__ == "__main__":
    run()
orphaned-records.js
/**
 * Detect DNS records left behind after a service teardown, and optionally
 * repair the zone via Cloudflare. Safe by default. Set DRY_RUN=false to let
 * it write.
 */
import { pathToFileURL } from "node:url";

const DNS_DOMAIN = process.env.DNS_DOMAIN || "example.com";
const CLOUDFLARE_API_TOKEN = process.env.CLOUDFLARE_API_TOKEN || "";
const CLOUDFLARE_ZONE_ID = process.env.CLOUDFLARE_ZONE_ID || "";
const LIVE_INVENTORY = new Set(
  (process.env.LIVE_INVENTORY || "")
    .split(",")
    .map((item) => item.trim().toLowerCase())
    .filter(Boolean),
);
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";

const CF_API = "https://api.cloudflare.com/client/v4";

const UNCLAIMED_FINGERPRINTS = {
  "s3-website-us-east-1.amazonaws.com": "NoSuchBucket",
  "herokuapp.com": "no-such-app",
  "github.io": "There isn't a GitHub Pages site here",
};

/**
 * Pure decision function. No I/O.
 *
 * record: { type: "CNAME"|"A"|"AAAA", name: string, content: string }
 * liveInventory: Set of hostnames/IPs currently provisioned (from a cloud
 *   API or CMDB snapshot), already lowercased with no trailing dot.
 * unclaimedFingerprints: { providerDomainSuffix: expected404BodySubstring }
 *   for known dangling signatures.
 *
 * Returns one of "orphaned", "active", "needs_manual_review".
 */
export function classifyRecord(record, liveInventory, unclaimedFingerprints) {
  const target = record.content.replace(/\.$/, "").toLowerCase();

  if (liveInventory.has(target)) return "active";

  for (const suffix of Object.keys(unclaimedFingerprints)) {
    if (target.endsWith(suffix) && !liveInventory.has(target)) return "orphaned";
  }

  return "needs_manual_review";
}

/** List CNAME, A, and AAAA records in the zone via the Cloudflare API. */
async function listZoneRecords() {
  const headers = { Authorization: `Bearer ${CLOUDFLARE_API_TOKEN}` };
  const records = [];
  for (const recordType of ["CNAME", "A", "AAAA"]) {
    const params = new URLSearchParams({ type: recordType, per_page: "5000" });
    const res = await fetch(
      `${CF_API}/zones/${CLOUDFLARE_ZONE_ID}/dns_records?${params.toString()}`,
      { headers },
    );
    if (!res.ok) throw new Error(`Cloudflare list returned ${res.status}`);
    const body = await res.json();
    for (const rec of body.result) {
      records.push({ id: rec.id, type: rec.type, name: rec.name, content: rec.content });
    }
  }
  return records;
}

/** Delete a single DNS record through the Cloudflare API. */
async function deleteRecord(recordId) {
  const headers = { Authorization: `Bearer ${CLOUDFLARE_API_TOKEN}` };
  if (DRY_RUN) {
    console.log(`[dry run] would delete record ${recordId}`);
    return;
  }
  const res = await fetch(`${CF_API}/zones/${CLOUDFLARE_ZONE_ID}/dns_records/${recordId}`, {
    method: "DELETE",
    headers,
  });
  if (!res.ok) throw new Error(`Cloudflare delete returned ${res.status}`);
  console.log(`Deleted orphaned record ${recordId}`);
}

async function run() {
  if (!(CLOUDFLARE_API_TOKEN && CLOUDFLARE_ZONE_ID)) {
    console.warn(`No Cloudflare credentials set. Nothing to scan for ${DNS_DOMAIN}.`);
    return;
  }

  const records = await listZoneRecords();
  let orphaned = 0;
  for (const record of records) {
    const verdict = classifyRecord(record, LIVE_INVENTORY, UNCLAIMED_FINGERPRINTS);
    if (verdict === "orphaned") {
      console.log(`${record.name} -> ${record.content} is orphaned`);
      await deleteRecord(record.id);
      orphaned++;
    } else if (verdict === "needs_manual_review") {
      console.warn(`${record.name} -> ${record.content} needs manual review`);
    } else {
      console.log(`${record.name} -> ${record.content} is active`);
    }
  }

  console.log(`Done. ${orphaned} orphaned record(s) ${DRY_RUN ? "to remove" : "removed"}.`);
}

if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
  run().catch((err) => {
    console.error(err);
    process.exit(1);
  });
}

Add a test

The decision rule is the part most worth testing, because it decides which records get deleted. Because classify_record is pure, the test needs no network and no Cloudflare account. It just feeds in plain objects and checks the verdict.

test_orphaned_records.py
from orphaned_records import classify_record, UNCLAIMED_FINGERPRINTS


def rec(name, type_, content):
    return {"name": name, "type": type_, "content": content}


def test_active_when_target_in_live_inventory():
    record = rec("app.example.com", "CNAME", "app.herokuapp.com")
    live_inventory = {"app.herokuapp.com"}
    assert classify_record(record, live_inventory, UNCLAIMED_FINGERPRINTS) == "active"


def test_orphaned_when_target_matches_known_suffix_and_not_live():
    record = rec("old-app.example.com", "CNAME", "old-app.herokuapp.com")
    live_inventory = {"app-v2.herokuapp.com"}
    assert classify_record(record, live_inventory, UNCLAIMED_FINGERPRINTS) == "orphaned"


def test_needs_manual_review_for_unknown_target():
    record = rec("mystery.example.com", "CNAME", "mystery.internal-tool.com")
    live_inventory = set()
    assert classify_record(record, live_inventory, UNCLAIMED_FINGERPRINTS) == "needs_manual_review"


def test_trailing_dot_and_case_are_normalized():
    record = rec("app.example.com", "CNAME", "App.Herokuapp.com.")
    live_inventory = {"app.herokuapp.com"}
    assert classify_record(record, live_inventory, UNCLAIMED_FINGERPRINTS) == "active"
classify-record.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { classifyRecord } from "./orphaned-records.js";

const UNCLAIMED_FINGERPRINTS = {
  "s3-website-us-east-1.amazonaws.com": "NoSuchBucket",
  "herokuapp.com": "no-such-app",
  "github.io": "There isn't a GitHub Pages site here",
};

const rec = (name, type, content) => ({ name, type, content });

test("active when target is in live inventory", () => {
  const record = rec("app.example.com", "CNAME", "app.herokuapp.com");
  const liveInventory = new Set(["app.herokuapp.com"]);
  assert.equal(classifyRecord(record, liveInventory, UNCLAIMED_FINGERPRINTS), "active");
});

test("orphaned when target matches known suffix and is not live", () => {
  const record = rec("old-app.example.com", "CNAME", "old-app.herokuapp.com");
  const liveInventory = new Set(["app-v2.herokuapp.com"]);
  assert.equal(classifyRecord(record, liveInventory, UNCLAIMED_FINGERPRINTS), "orphaned");
});

test("needs manual review for unknown target", () => {
  const record = rec("mystery.example.com", "CNAME", "mystery.internal-tool.com");
  const liveInventory = new Set();
  assert.equal(classifyRecord(record, liveInventory, UNCLAIMED_FINGERPRINTS), "needs_manual_review");
});

test("trailing dot and case are normalized", () => {
  const record = rec("app.example.com", "CNAME", "App.Herokuapp.com.");
  const liveInventory = new Set(["app.herokuapp.com"]);
  assert.equal(classifyRecord(record, liveInventory, UNCLAIMED_FINGERPRINTS), "active");
});

Case studies

Marketing campaign

The landing page that outlived the campaign

A marketing team spun up promo.example.com as a CNAME to a page builder's hosted subdomain for a two-week campaign. When the campaign ended, the page builder account was closed, but the DNS record stayed in the zone for over a year. A researcher scanning certificate transparency logs found the subdomain, registered the exact same name on the page builder's free tier, and put up a phishing page under the company's own domain.

Deleting the leftover CNAME closed the report the same day. The marketing team now tags every campaign subdomain with an expiry date in a shared tracker.

Cloud migration

The staging box that never got its A record removed

An engineering team migrated staging environments to a new provider and terminated the old EC2 instances, but the A record for staging.example.com still pointed at the old, now unassigned, IP address. The IP was later reassigned by the cloud provider to a different customer's instance, and staging traffic briefly reached infrastructure the company no longer controlled.

Running the inventory diff script against the current EC2 host list caught the stale A record immediately, and repointing it to the new staging load balancer closed the gap for good.

What good looks like

Every record in the zone matches something you can point to today in your infrastructure inventory. Decommissioning a service always includes a DNS cleanup step, and where the provider supports it, a TXT ownership marker is in place so a hostname cannot be claimed by anyone else even during the short window before cleanup happens.

FAQ

What is an orphaned or dangling DNS record?

It is a DNS record, usually a CNAME or A record, that still points at a cloud resource, load balancer, or SaaS app that has since been deleted or decommissioned. The record itself is a separate object in the zone, so nobody is forced to remove it when the underlying resource goes away, and it keeps resolving to something that no longer exists.

Why is an orphaned DNS record a security risk, not just clutter?

Many cloud and SaaS platforms let anyone claim an unused hostname or bucket name. If your record still points at that exact name, an attacker who finds it through subdomain enumeration or certificate transparency logs can provision their own resource there and start serving content under your trusted domain, often with a valid certificate.

How do I find orphaned DNS records in my zone?

List every record in the zone and resolve what each one points at. A CNAME target that returns NXDOMAIN or SERVFAIL, or an HTTP response with a platform not found fingerprint like NoSuchBucket or no such app, is the signature of a dangling record. Cross-check the survivors against your current infrastructure inventory to catch anything that resolves fine but no longer matches a resource you actually run.

Related field notes

Citations

On the problem:

  1. Microsoft Learn: Prevent subdomain takeovers with Azure DNS alias records and Azure App Service's custom domain verification. learn.microsoft.com/en-us/azure/security/fundamentals/subdomain-takeover
  2. Palo Alto Networks Cyberpedia: What Is Dangling DNS? paloaltonetworks.com/cyberpedia/what-is-a-dangling-dns
  3. DNS Made Easy: Dangling DNS Records and the Risk of Domain Hijacking. dnsmadeeasy.com/resources/dangling-dns-records-and-the-risk-of-domain-hijacking

On the solution:

  1. Cloudflare API: Delete DNS Record. developers.cloudflare.com/api/resources/dns/subresources/records/methods/delete
  2. Cloudflare API: List DNS Records. developers.cloudflare.com/api/resources/dns/subresources/records/methods/list
  3. Cloudflare DNS docs: Manage DNS records. developers.cloudflare.com/dns/manage-dns-records/how-to/create-dns-records

Stuck on a tricky one?

If you have a DNS, email deliverability, or certificate problem you would rather hand off, this is the kind of work I do. Message me and we can work through it together.

Contact me on LinkedIn

Did this close your takeover risk?

If this saved you a security incident or an awkward disclosure email, you can buy me a coffee. It is the best way to keep these field notes free and growing.

Buy me a coffee on Ko-fi

Back to all DNS and Domains field notes