Diagnostic Security / Takeover Risk

Dangling CNAME chain with a claimable intermediate hop

A subdomain's CNAME does not always point straight at a live host. Sometimes it points to another CNAME, which points to another CNAME, and only after a few hops does it reach a real server. If one of the names in the middle of that chain points at a cloud resource that has since been deleted, that hop is dangling, and most quick checks never notice because they only look at the first hop. Here is how to walk the whole chain, find the broken hop, and close it before someone else claims it.

dig and whois Python and Node.js Fixable through the DNS API
Teal led panel
Photo by Adi Goldstein on Unsplash
The short answer

A CNAME chain can resolve fine at the top and still be broken two or three hops later, if one of the in-between names points at a CDN endpoint, load balancer, or PaaS app that no longer exists. Because most scanners and health checks only resolve the first CNAME and stop, that break stays invisible while it sits there, claimable by anyone who registers the same resource name at the vendor. The fix is to walk every hop one at a time with dig until you find the exact name that returns NXDOMAIN, then repoint or delete the record you control so it never reaches that dead resource.

The problem in plain words

A CNAME record does not have to point at a server. It can point at another name, which points at another name, and so on, until the chain finally lands on an A record, an AAAA record, or nothing at all. Each of those names is called a hop. A short chain might be one hop. A chain built up over years of vendors and re-platforming can easily be three or four hops long.

The danger is in the middle. If the first hop and the last hop both look fine, it is easy to assume the whole chain is fine. But one of the names in between might point at a cloud resource, a CDN distribution, a load balancer alias, a PaaS app name, that its owner deleted or deprovisioned a long time ago. That one name now resolves to nothing, NXDOMAIN or a vendor "no such app" page, even though the hop before it and the hop after it were never touched. Most quick health checks, and a lot of takeover scanners, resolve only the first CNAME in the chain and call it done. They never walk far enough to see the break.

app.example.com hop 1, resolves fine cdn-edge.vendorone.net hop 2, resolves fine lb-shared.vendortwo.io hop 3, deprovisioned NXDOMAIN A scanner that only resolves the first hop sees a normal-looking chain and never notices hop 3 is claimable by anyone who registers that vendor resource name
Hop 1 and hop 2 both resolve without trouble. Hop 3, the intermediate name pointing at a deprovisioned vendor resource, is the one that is actually dangling, and a shallow check never reaches it.

Why it happens

The mistake to avoid is running dig CNAME app.example.com once, seeing an answer, and stopping there. A recursive resolver sometimes flattens the whole chain into one answer set, which can hide exactly where the break is, or the query only follows one link and never reaches the dead hop at all. Every name in the chain has to be resolved on its own until you hit a terminal A or AAAA record, or NXDOMAIN.

The key insight

A CNAME chain is only as safe as its weakest hop, not its first or its last. The first hop resolving is not proof the chain is healthy. You have to walk from the original name to the final terminal record, one hop at a time, and confirm every single name in between still answers with something you own.

The fix, as a flow

Start at the subdomain you are worried about and resolve its CNAME. Take whatever name comes back and resolve that one too. Keep going, one hop at a time, until you reach an A or AAAA record, or until one of the names comes back NXDOMAIN. The moment you hit NXDOMAIN, or a vendor page saying the resource is not configured, that is your dangling hop. Then check whether that hop's target is a record you control. If it is, repoint it or delete it so nothing in your zone still points at a dead resource.

dig +short CNAME the top-level name Resolve the next hop by itself Repeat until A/AAAA or NXDOMAIN hit Every hop still live? yes no, repoint or delete Chain confirmed clean, no NXDOMAIN
Walk every hop one at a time. The moment a name is NXDOMAIN, that is the dangling hop. Fix the record you control that points into it, then re-walk the whole chain to confirm it is clean end to end.

How to fix it

1

Resolve the first hop by itself

Do not stop at a chain that "resolves." Take the exact target that the first CNAME returns and write it down. That is the name you resolve next, on its own.

Terminal
check-hop-1 (shell)
dig +short CNAME app.example.com
# e.g. returns: cdn-edge.vendorone.net.
2

Resolve every following hop one at a time

Take the target from step 1 and resolve it on its own. Keep repeating this for each new name the previous lookup returns, until you reach an A or AAAA record, or until a lookup comes back empty. This is the step most quick checks skip, and it is the only way to see a break that happens two or three hops in.

Terminal
check-each-hop (shell)
dig +short CNAME cdn-edge.vendorone.net
# e.g. returns: lb-shared.vendortwo.io.   (the intermediate hop)

dig +short CNAME lb-shared.vendortwo.io
# e.g. returns: origin.customer-apps.vendortwo.io.

dig +short A origin.customer-apps.vendortwo.io
# empty, or:
dig +short ANY origin.customer-apps.vendortwo.io
# also empty
3

Confirm the break with a full dig, not just +short

+short hides the status header, and that header is what tells you NXDOMAIN from an empty-but-valid answer. Run a full dig on the hop you suspect is dangling and read the header line.

Terminal
confirm-break (shell)
dig lb-shared.vendortwo.io

# a bad result looks like:
# ;; ->>HEADER<<- opcode: QUERY, status: NXDOMAIN, id: 40211
# ;; ANSWER SECTION: (empty, no A record at all)

nslookup -type=CNAME cdn-edge.vendorone.net
nslookup lb-shared.vendortwo.io
# both should agree on the same NXDOMAIN independently
4

Check whether the dangling name is claimable

Look up the parent domain of the dangling hop with whois or RDAP. If the domain shows unregistered, or the vendor's own naming pattern is one they let expire on old CDN, load balancer, or PaaS aliases, treat the hop as claimable and fix your side right away.

Terminal
check-claimable (shell)
whois vendortwo.io
# a bad result shows "No match for domain" or similar,
# meaning the vendor resource behind this name is up for grabs
5

Repoint or delete the record you control

You cannot fix the vendor's side of a dangling hop, but you can always fix your own zone. Find the record that starts or continues the chain into that dead resource and either delete it or point it at something you actually own now.

DNS record
zone record
; before, cdn-edge points into the dead resource:
cdn-edge.vendorone.net.  300  IN  CNAME  lb-shared.vendortwo.io.

; after, repointed at a live resource you actually own:
cdn-edge.vendorone.net.  300  IN  CNAME  app-prod.mycdnaccount.vendorone.net.

; or, if the subdomain is not needed anymore, remove the top of the chain entirely:
; (delete) app.example.com  CNAME  cdn-edge.vendorone.net.

; optional: lock down issuance while the record is being cleaned up
example.com.  IN  CAA  0  issue  "letsencrypt.org"
6

Update it in Cloudflare, by dashboard or by API

In Cloudflare's dashboard, go to DNS, Records, find the record you control that targets the dangling hop, and edit or delete it. To do the same thing through the API, send a PATCH with a corrected content, or a DELETE if you no longer need the record.

Terminal
patch-or-delete-record (shell)
curl -X PATCH "https://api.cloudflare.com/client/v4/zones/{zone_id}/dns_records/{dns_record_id}" \
  -H "Authorization: Bearer {CLOUDFLARE_API_TOKEN}" \
  -H "Content-Type: application/json" \
  --data '{"content":"app-prod.mycdnaccount.vendorone.net"}'

# or, to remove the record entirely:
curl -X DELETE "https://api.cloudflare.com/client/v4/zones/{zone_id}/dns_records/{dns_record_id}" \
  -H "Authorization: Bearer {CLOUDFLARE_API_TOKEN}"

How to check it worked

Re-walk the chain from the very top the same way you found the break. Every hop should now answer, none should show NXDOMAIN, and the chain should end in a real A or AAAA record. Then check the certificate on the live endpoint so you know it belongs to your service, not a leftover vendor placeholder.

Terminal
verify (shell)
dig +short CNAME app.example.com
dig +short CNAME cdn-edge.vendorone.net
dig +short A app-prod.mycdnaccount.vendorone.net

dig app.example.com A
# a good answer looks like:
# ;; ->>HEADER<<- opcode: QUERY, status: NOERROR, id: 5521
# app.example.com.  300  IN  A  203.0.113.20

nslookup app.example.com

openssl s_client -connect app.example.com:443 -servername app.example.com </dev/null 2>/dev/null \
  | openssl x509 -noout -subject -issuer

curl -Iv https://app.example.com
# should return your app's real response, not a
# "domain not configured" or "no such app" placeholder page

The full code

Here is a small script that walks a CNAME chain hop by hop, flags the first hop anywhere in the chain that comes back NXDOMAIN or fails, and, when the offending record lives in your own zone, repairs it through the Cloudflare API. It stays in dry run by default so it reports before it writes.

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

dangling_cname_chain.py
"""Walk a CNAME chain hop by hop, find the dangling intermediate hop, and
repair the record you control via Cloudflare. Safe to run on a schedule.
Stays in dry run until DRY_RUN=false.
"""
import os
import logging

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


def find_dangling_hop(chain: list, max_depth: int = 10):
    """Pure decision logic, no I/O. The DNS resolution itself happens in run().

    Each item in chain is a dict:
      {"hostname": str, "is_cname": bool, "target": str | None,
       "resolved_status": "OK" | "NXDOMAIN" | "SERVFAIL"}

    Returns the first dict (scanning every hop, not just the first) whose
    resolved_status is NXDOMAIN or SERVFAIL, or None if the whole chain
    resolves cleanly within max_depth hops. Returns a chain-too-long marker
    if the chain exceeds max_depth without terminating.
    """
    if len(chain) > max_depth:
        return {"hostname": chain[max_depth]["hostname"], "reason": "chain-too-long"}
    for hop in chain:
        if hop["resolved_status"] in ("NXDOMAIN", "SERVFAIL"):
            return hop
    return None


def run():
    # Imported lazily so the pure function above can be tested with no
    # network libraries installed at all.
    import dns.resolver
    import dns.rdatatype
    import requests

    domain = os.environ["DNS_DOMAIN"]
    max_depth = int(os.environ.get("MAX_DEPTH", "10"))
    dry_run = os.environ.get("DRY_RUN", "true").lower() == "true"

    zone_id = os.environ.get("CLOUDFLARE_ZONE_ID")
    api_token = os.environ.get("CLOUDFLARE_API_TOKEN")

    resolver = dns.resolver.Resolver()
    chain = []
    current = domain

    for _ in range(max_depth + 1):
        try:
            answer = resolver.resolve(current, "CNAME")
            target = str(answer[0].target).rstrip(".")
            chain.append({"hostname": current, "is_cname": True, "target": target,
                          "resolved_status": "OK"})
            current = target
            continue
        except dns.resolver.NXDOMAIN:
            chain.append({"hostname": current, "is_cname": False, "target": None,
                          "resolved_status": "NXDOMAIN"})
            break
        except dns.resolver.NoAnswer:
            # No CNAME here. Either it is a terminal A/AAAA record (OK) or
            # nothing resolves at all for this name (treat as SERVFAIL-like).
            try:
                resolver.resolve(current, "A")
                chain.append({"hostname": current, "is_cname": False, "target": None,
                              "resolved_status": "OK"})
            except Exception:
                chain.append({"hostname": current, "is_cname": False, "target": None,
                              "resolved_status": "SERVFAIL"})
            break
        except Exception:
            chain.append({"hostname": current, "is_cname": False, "target": None,
                          "resolved_status": "SERVFAIL"})
            break

    dangling = find_dangling_hop(chain, max_depth=max_depth)

    if dangling is None:
        log.info("Chain for %s resolves cleanly, %d hop(s), no dangling hop found.",
                  domain, len(chain))
        return

    if dangling.get("reason") == "chain-too-long":
        log.warning("Chain for %s exceeded max depth %d, possible loop.", domain, max_depth)
        return

    log.warning("Dangling hop found: %s (status=%s)", dangling["hostname"], dangling["resolved_status"])

    # Find the record in our own zone whose target is the hop just before the
    # dangling one, since that is the record we can actually repair.
    broken_index = chain.index(dangling)
    if broken_index == 0:
        log.warning("The dangling hop is the top-level name itself. Nothing upstream to repoint.")
        return

    owned_record_name = chain[broken_index - 1]["hostname"]
    log.warning("Record to repair: %s (currently points at %s)", owned_record_name, dangling["hostname"])

    if not zone_id or not api_token:
        log.info("No Cloudflare credentials set, skipping repair. %s would be repointed or deleted.",
                  owned_record_name)
        return

    replacement_target = os.environ.get("REPLACEMENT_TARGET")
    log.info("%s record %s to %s.",
             "Would repoint" if dry_run else "Repointing", owned_record_name,
             replacement_target or "(delete, no replacement target set)")

    if dry_run:
        return

    headers = {"Authorization": f"Bearer {api_token}", "Content-Type": "application/json"}
    base = f"https://api.cloudflare.com/client/v4/zones/{zone_id}/dns_records"

    lookup = requests.get(base, headers=headers, params={"name": owned_record_name}, timeout=30)
    lookup.raise_for_status()
    records = lookup.json().get("result", [])
    if not records:
        log.warning("Could not find a Cloudflare DNS record named %s to repair.", owned_record_name)
        return
    record_id = records[0]["id"]

    if replacement_target:
        resp = requests.patch(f"{base}/{record_id}", headers=headers,
                               json={"content": replacement_target}, timeout=30)
    else:
        resp = requests.delete(f"{base}/{record_id}", headers=headers, timeout=30)
    resp.raise_for_status()
    log.info("Repaired %s.", owned_record_name)


if __name__ == "__main__":
    run()
dangling-cname-chain.js
/**
 * Walk a CNAME chain hop by hop, find the dangling intermediate hop, and
 * repair the record you control via Cloudflare. Safe to run on a schedule.
 * Stays in dry run until DRY_RUN=false.
 */
import { pathToFileURL } from "node:url";

export function findDanglingHop(chain, maxDepth = 10) {
  // Pure decision logic, no I/O. The DNS resolution itself happens in run().
  //
  // Each item in chain is an object:
  //   { hostname, isCname, target, resolvedStatus: "OK" | "NXDOMAIN" | "SERVFAIL" }
  //
  // Returns the first item (scanning every hop, not just the first) whose
  // resolvedStatus is NXDOMAIN or SERVFAIL, or null if the whole chain
  // resolves cleanly within maxDepth hops. Returns a chain-too-long marker
  // if the chain exceeds maxDepth without terminating.
  if (chain.length > maxDepth) {
    return { hostname: chain[maxDepth].hostname, reason: "chain-too-long" };
  }
  for (const hop of chain) {
    if (hop.resolvedStatus === "NXDOMAIN" || hop.resolvedStatus === "SERVFAIL") {
      return hop;
    }
  }
  return null;
}

export async function run() {
  // Imported lazily so the pure function above can be tested with no
  // network modules touched at all.
  const dns = await import("node:dns");
  const resolver = dns.promises;

  const domain = process.env.DNS_DOMAIN;
  const maxDepth = Number(process.env.MAX_DEPTH || 10);
  const dryRun = (process.env.DRY_RUN || "true").toLowerCase() === "true";

  const zoneId = process.env.CLOUDFLARE_ZONE_ID;
  const apiToken = process.env.CLOUDFLARE_API_TOKEN;

  const chain = [];
  let current = domain;

  for (let i = 0; i <= maxDepth; i++) {
    try {
      const answers = await resolver.resolveCname(current);
      const target = answers[0];
      chain.push({ hostname: current, isCname: true, target, resolvedStatus: "OK" });
      current = target;
      continue;
    } catch (err) {
      if (err.code === "ENOTFOUND") {
        chain.push({ hostname: current, isCname: false, target: null, resolvedStatus: "NXDOMAIN" });
        break;
      }
      if (err.code === "ENODATA") {
        try {
          await resolver.resolve4(current);
          chain.push({ hostname: current, isCname: false, target: null, resolvedStatus: "OK" });
        } catch {
          chain.push({ hostname: current, isCname: false, target: null, resolvedStatus: "SERVFAIL" });
        }
        break;
      }
      chain.push({ hostname: current, isCname: false, target: null, resolvedStatus: "SERVFAIL" });
      break;
    }
  }

  const dangling = findDanglingHop(chain, maxDepth);

  if (dangling === null) {
    console.log(`Chain for ${domain} resolves cleanly, ${chain.length} hop(s), no dangling hop found.`);
    return;
  }

  if (dangling.reason === "chain-too-long") {
    console.warn(`Chain for ${domain} exceeded max depth ${maxDepth}, possible loop.`);
    return;
  }

  console.warn(`Dangling hop found: ${dangling.hostname} (status=${dangling.resolvedStatus})`);

  const brokenIndex = chain.indexOf(dangling);
  if (brokenIndex === 0) {
    console.warn("The dangling hop is the top-level name itself. Nothing upstream to repoint.");
    return;
  }

  const ownedRecordName = chain[brokenIndex - 1].hostname;
  console.warn(`Record to repair: ${ownedRecordName} (currently points at ${dangling.hostname})`);

  if (!zoneId || !apiToken) {
    console.log(`No Cloudflare credentials set, skipping repair. ${ownedRecordName} would be repointed or deleted.`);
    return;
  }

  const replacementTarget = process.env.REPLACEMENT_TARGET;
  console.log(`${dryRun ? "Would repoint" : "Repointing"} record ${ownedRecordName} to ${replacementTarget || "(delete, no replacement target set)"}.`);

  if (dryRun) return;

  const headers = { Authorization: `Bearer ${apiToken}`, "Content-Type": "application/json" };
  const base = `https://api.cloudflare.com/client/v4/zones/${zoneId}/dns_records`;

  const lookupRes = await fetch(`${base}?name=${encodeURIComponent(ownedRecordName)}`, { headers });
  if (!lookupRes.ok) throw new Error(`Cloudflare API returned ${lookupRes.status}`);
  const lookupBody = await lookupRes.json();
  const records = lookupBody.result || [];
  if (records.length === 0) {
    console.warn(`Could not find a Cloudflare DNS record named ${ownedRecordName} to repair.`);
    return;
  }
  const recordId = records[0].id;

  const patchRes = replacementTarget
    ? await fetch(`${base}/${recordId}`, {
        method: "PATCH",
        headers,
        body: JSON.stringify({ content: replacementTarget }),
      })
    : await fetch(`${base}/${recordId}`, { method: "DELETE", headers });

  if (!patchRes.ok) throw new Error(`Cloudflare API returned ${patchRes.status}`);
  console.log(`Repaired ${ownedRecordName}.`);
}

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 chain walker's decision logic is the part worth testing on its own, because it decides which hop gets flagged as dangling and, from that, which record gets touched. It takes plain lists of dicts or objects, so the test needs no network and no DNS library at all.

test_dangling.py
from dangling_cname_chain import find_dangling_hop


def hop(hostname, status, target=None, is_cname=True):
    return {"hostname": hostname, "is_cname": is_cname, "target": target, "resolved_status": status}


def test_clean_chain_returns_none():
    chain = [
        hop("app.example.com", "OK", "cdn-edge.vendorone.net"),
        hop("cdn-edge.vendorone.net", "OK", "lb-shared.vendortwo.io"),
        hop("lb-shared.vendortwo.io", "OK", None, is_cname=False),
    ]
    assert find_dangling_hop(chain) is None


def test_flags_intermediate_hop_not_just_first():
    chain = [
        hop("app.example.com", "OK", "cdn-edge.vendorone.net"),
        hop("cdn-edge.vendorone.net", "OK", "lb-shared.vendortwo.io"),
        hop("lb-shared.vendortwo.io", "NXDOMAIN", None, is_cname=False),
    ]
    result = find_dangling_hop(chain)
    assert result["hostname"] == "lb-shared.vendortwo.io"


def test_flags_servfail_hop():
    chain = [
        hop("app.example.com", "OK", "cdn-edge.vendorone.net"),
        hop("cdn-edge.vendorone.net", "SERVFAIL", None, is_cname=False),
    ]
    result = find_dangling_hop(chain)
    assert result["hostname"] == "cdn-edge.vendorone.net"


def test_chain_too_long_flags_possible_loop():
    chain = [hop(f"hop{i}.example.com", "OK", f"hop{i+1}.example.com") for i in range(12)]
    result = find_dangling_hop(chain, max_depth=10)
    assert result["reason"] == "chain-too-long"
dangling-cname-chain.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { findDanglingHop } from "./dangling-cname-chain.js";

function hop(hostname, resolvedStatus, target = null, isCname = true) {
  return { hostname, isCname, target, resolvedStatus };
}

test("clean chain returns null", () => {
  const chain = [
    hop("app.example.com", "OK", "cdn-edge.vendorone.net"),
    hop("cdn-edge.vendorone.net", "OK", "lb-shared.vendortwo.io"),
    hop("lb-shared.vendortwo.io", "OK", null, false),
  ];
  assert.equal(findDanglingHop(chain), null);
});

test("flags intermediate hop, not just first", () => {
  const chain = [
    hop("app.example.com", "OK", "cdn-edge.vendorone.net"),
    hop("cdn-edge.vendorone.net", "OK", "lb-shared.vendortwo.io"),
    hop("lb-shared.vendortwo.io", "NXDOMAIN", null, false),
  ];
  const result = findDanglingHop(chain);
  assert.equal(result.hostname, "lb-shared.vendortwo.io");
});

test("flags servfail hop", () => {
  const chain = [
    hop("app.example.com", "OK", "cdn-edge.vendorone.net"),
    hop("cdn-edge.vendorone.net", "SERVFAIL", null, false),
  ];
  const result = findDanglingHop(chain);
  assert.equal(result.hostname, "cdn-edge.vendorone.net");
});

test("chain too long flags possible loop", () => {
  const chain = Array.from({ length: 12 }, (_, i) =>
    hop(`hop${i}.example.com`, "OK", `hop${i + 1}.example.com`));
  const result = findDanglingHop(chain, 10);
  assert.equal(result.reason, "chain-too-long");
});

Case studies

CDN migration

The load balancer alias nobody re-created

A company moved its CDN provider two years ago. The top-level subdomain was updated to point at the new provider, but an old intermediate alias, a shared load balancer name from the previous vendor, was left in the chain because a second internal record still pointed at it as a stepping stone. When the old vendor account was finally closed, that alias started returning NXDOMAIN.

A takeover scanner had run monthly for a year and never flagged it, because it only resolved the first hop, which still pointed at the new, working CDN entry point for an unrelated part of the chain. Walking every hop by hand found the dead alias in about ten minutes.

Acquired subdomain

The PaaS app that outlived its project

A subdomain used for an internal demo pointed, through two hops, at a PaaS app that the original project team deleted when the demo wrapped up. Nobody removed the DNS record because the top-level name was owned by a different team than the one that had provisioned the app.

Whois on the PaaS provider's domain showed the resource pattern was registered and freely available for anyone with an account. The fix was deleting the top-level CNAME entirely, since the subdomain was no longer needed by anyone.

What good looks like

Once the record is fixed, walking the chain from the top with dig +short CNAME at each hop returns a real answer at every step, with no NXDOMAIN anywhere in the middle, and it ends in an A or AAAA record you recognize. openssl s_client against the final hostname shows a certificate issued by your own CA, and the page it serves is your application, not a vendor's placeholder. Run the chain walker on a schedule so a future vendor cleanup gets caught before anyone else notices it first.

FAQ

Why does my CNAME chain look fine but a subdomain still gets flagged as a takeover risk?

Most quick checks only resolve the first CNAME hop and stop. A chain can have three or four hops before it reaches a real server, and the break can sit in the middle, not at the start or the end. You have to resolve every hop one at a time to find it.

What makes an intermediate hop claimable by an attacker?

It is claimable when the hop points at a cloud resource name, like a CDN endpoint or a PaaS app, that has been deleted at the vendor but the DNS record pointing to it is still live. Anyone who can register that same resource name at the vendor can then serve their own content under your subdomain.

Can I fix the dangling hop if I do not own that part of the chain?

You cannot fix a hop that lives in a vendor's system you do not control, but you can always fix the record in your own zone that points into it. Repoint or delete the CNAME record you own so it never reaches the dead resource, or re-provision the resource yourself if you still need it.

Related field notes

Citations

On the problem:

  1. Microsoft Azure: Prevent dangling DNS entries and avoid subdomain takeover. learn.microsoft.com/en-us/azure/security/fundamentals/subdomain-takeover
  2. Censys: How a dangling DNS entry can lead to a subdomain takeover. censys.com/blog/dangling-dns-subdomain-takeover
  3. haccer/subjack: DNS takeover tool with multi-level CNAME chain support. github.com/haccer/subjack

On the solution:

  1. Cloudflare API: update a DNS record (PATCH). developers.cloudflare.com/api/resources/dns/subresources/records/methods/edit
  2. Cloudflare DNS docs: manage DNS records. developers.cloudflare.com/dns/manage-dns-records/how-to/create-dns-records
  3. AWS Security Blog: threat tactic spotlight, subdomain takeover. aws.amazon.com/blogs/security/threat-tactic-spotlight-subdomain-takeover

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 a dangling CNAME for you?

If this saved you from a subdomain takeover, 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