A subdomain takeover needs two ingredients: a DNS record you forgot to delete, and a platform that lets a stranger claim whatever that record points to. You fully control the first ingredient, so prevention comes down to the order in which you remove things and an audit that you actually repeat.

How a record starts dangling

Microsoft’s guidance describes the common sequence, and it applies well beyond Azure:

  1. You create a platform resource with its own hostname, such as <app-name>.azurewebsites.net, and point shop.example.com at it with a CNAME.
  2. The resource is deleted. The CNAME stays, still advertising a name that no longer serves anything you own.
  3. Someone else creates a resource with the same platform hostname, and traffic for shop.example.com now reaches their content.

Platforms become claimable in two ways. Some hand out globally unique resource names and release them on deletion, so the next customer to pick the name inherits your CNAME. Others route on the custom hostname configured in a project: GitHub notes that deleting a repository or downgrading a plan can unlink a custom domain while DNS still points at GitHub Pages, and an unverified domain can then be attached by another account.

What the new owner gets is more than a defaced page. Microsoft and OWASP both list cookies scoped to the parent domain, publicly trusted TLS certificates issued for your subdomain, and convincing phishing pages. OWASP’s cheat sheet lists four record types that can dangle:

  • CNAME to a deleted platform resource, the most common case
  • A or AAAA to a cloud address that was released and can be reassigned
  • NS delegating a subdomain to a DNS provider account that was closed
  • MX to a deprovisioned mail service, which hands over the subdomain’s mail and any email-based certificate validation

For that last case, pair this audit with the email records for domains that send no mail.

What an unclaimed platform name looks like

You cannot tell from a CNAME alone whether its target is still yours. What you can see from outside is how a platform answers for a name nobody has deployed.

Live capture, 26 September 2026. A label we made up under GitHub Pages’ domain, queried through Cloudflare’s resolver from our test workstation (headers trimmed):

$ dig @1.1.1.1 andguide-takeover-demo-9x2q.github.io A +noall +comments +answer | grep -E 'status|IN'
;; ->>HEADER<<- opcode: QUERY, status: NOERROR, id: 38047
andguide-takeover-demo-9x2q.github.io. 3600 IN A 185.199.108.153
andguide-takeover-demo-9x2q.github.io. 3600 IN A 185.199.111.153
andguide-takeover-demo-9x2q.github.io. 3600 IN A 185.199.109.153
andguide-takeover-demo-9x2q.github.io. 3600 IN A 185.199.110.153
$ curl -sI https://andguide-takeover-demo-9x2q.github.io/
HTTP/2 404 
server: GitHub.com
content-type: text/html; charset=utf-8
...
$ curl -s https://andguide-takeover-demo-9x2q.github.io/ | grep -oE '<title>[^<]*|There isn.t a GitHub Pages site here\.'
<title>Site not found &middot; GitHub Pages
There isn't a GitHub Pages site here.

The DNS answer looks perfectly healthy: github.io returned four addresses for a label nobody registered. Only the HTTP body says nothing is deployed.

Live capture, 26 September 2026. We tried the same made-up label under four more platform domains, with two DNS queries and two HTTPS requests per name. Summary of the results, GitHub included:

Namespace DNS answer via 1.1.1.1 HTTPS status Body
github.io NOERROR, 4 addresses 404 GitHub Pages “site not found” page
vercel.app NOERROR, 2 addresses 404 DEPLOYMENT_NOT_FOUND error code
netlify.app NOERROR, 2 addresses 404 Plain “Not Found” with a request ID
herokuapp.com NOERROR, CNAME to an ingress name, 4 addresses 404 Page titled “No such app”
pages.dev NXDOMAIN No connection None

Two families appear. Four of the five platforms answered a label nobody had claimed, so a dangling CNAME pointing at them keeps resolving and only the HTTP fingerprint gives it away. pages.dev returned NXDOMAIN for the unclaimed label, so a CNAME to a deleted target there shows in dig as the CNAME line plus status: NXDOMAIN: RFC 6604 says the response code describes the last name in the chain, not the name you asked for.

A fingerprint means “nothing is deployed here now”, not “anyone can claim this”. Whether a name can be re-registered depends on each platform’s rules, which change; OWASP’s cheat sheet keeps a per-provider table for that reason. Check claimability only on names you own. The DNS lookup tool is handy for comparing what two public resolvers return for your own records.

Remove DNS first, delete the resource second

Every source we checked agrees on the order. MDN says to start provisioning by claiming the virtual host and create DNS records last, and to start deprovisioning by removing DNS records first. OWASP adds a wait of at least the record’s TTL before the resource goes. Microsoft suggests putting “remove DNS entry” on the decommissioning checklist and adding delete locks to resources that have custom DNS as a reminder. Cloudflare’s own Pages instructions follow the same order: delete the CNAME record, then remove the custom domain from the project.

The procedure:

  1. List every record that references the resource: CNAME, A/AAAA, MX, verification TXT records, and any NS delegation.
  2. If people still use the name, repoint it to something you run (a redirect or maintenance page) instead of leaving it on the platform.
  3. Delete or repoint the record at your DNS provider and note its TTL.
  4. Confirm the change at the authoritative servers, then wait at least one TTL so resolvers drop the old answer.
  5. Remove the custom-domain binding on the platform, then delete the resource.
  6. Check again from outside: the name should be NXDOMAIN or show its new target.
# Before: the record and its TTL, as the authoritative server serves it
dig @ns1.example.net shop.example.com CNAME +noall +answer

# After: the authority should report NXDOMAIN (or the new target)
dig @ns1.example.net shop.example.com CNAME +noall +comments | grep status

Provisioning runs the other way round: add the hostname on the platform and complete its verification, then publish DNS. GitHub warns that configuring DNS for a custom domain before adding it to GitHub could let someone else host a site on your subdomain; the GitHub Pages custom domain guide walks through that order.

Auditing a zone with Certificate Transparency: our own run

Your DNS dashboard only shows what exists today. crt.sh searches Certificate Transparency logs and keeps historical entries, so it lists the names on publicly logged certificates for your domain, including names you have forgotten. OWASP recommends watching the same logs for names you did not request.

We ran this sequence against our own zone.

Live capture, 26 September 2026. Step 1: collect every name crt.sh has seen on a certificate for and.guide, folding *.x entries into x:

$ curl -s --max-time 120 'https://crt.sh/?q=%25.and.guide&output=json' | jq -r '.[].name_value' | tr 'A-Z' 'a-z' | sed 's/^\*\.//' | sort -u > names.txt
$ wc -l < names.txt
     106

The JSON behind those 106 names held 1,875 certificate entries, the oldest issued in May 2021.

Live capture, 26 September 2026. Step 2: resolve each name through 1.1.1.1, then classify the first answer. The classifier prints a category instead of the target, so our other projects’ hostnames stay out of this page (15:18 UTC):

$ while read -r n; do
    printf '%s\t%s\n' "$n" "$(dig @1.1.1.1 +short "$n" A | head -1)"
  done < names.txt > dns.tsv
$ cat classify.awk
BEGIN { FS = "\t" }
$2 == "115.68.101.36"                { print "deployment server (wildcard address)"; next }
$2 ~ /\.pages\.dev\.$/               { print "CNAME to a Cloudflare Pages project"; next }
$2 ~ /^(104\.(1[6-9]|2[0-9]|3[01])|172\.6[4-7])\./ { print "Cloudflare address"; next }
$2 == ""                             { print "no answer"; next }
                                     { print "another address we run"; next }
$ awk -f classify.awk dns.tsv | sort | uniq -c | sort -rn
  96 deployment server (wildcard address)
   4 CNAME to a Cloudflare Pages project
   3 Cloudflare address
   2 another address we run
   1 no answer

Live capture, 26 September 2026. Step 3: every platform target must still exist:

$ for t in $(cut -f2 dns.tsv | grep 'pages\.dev\.$'); do
    dig @1.1.1.1 +noall +comments "$t" A | grep -o 'status: [A-Z]*'
  done | sort | uniq -c
   4 status: NOERROR

Live capture, 26 September 2026. Step 4: ask the server behind the 96 matching names whether it actually serves them:

$ awk -F'\t' '$2=="115.68.101.36" {print $1}' dns.tsv | while read -r n; do
    curl -s -o /dev/null --max-time 5 -w '%{http_code}\n' "http://$n/"
  done | sort | uniq -c
   1 302
  95 404

Put together:

Group Names Status on 26 September 2026
Our self-hosted deployment server 96 1 routed app; 95 get the server’s default 404
CNAMEs to Cloudflare Pages projects 4 All four targets exist
Names answered from Cloudflare addresses (apex, www, one app) 3 Answering
Names on our mail host’s address 2 Answering
NXDOMAIN 1 Sits below an existing CNAME (explained below)

So 105 of 106 names still resolve, but only 10 reach something deliberate: the routed app, the four Pages projects, the three names on Cloudflare addresses, and the two mail-host names. None of them pointed at a deleted platform resource. The 95 unrouted names are stale rather than dangling, because the address they resolve to is our own server, which answers hostnames it does not route with a default 404. Judging by their labels, most are old staging, demo, and test sites.

Two items still deserve standing attention. Each of the four Pages CNAMEs must be deleted before its project is. And the wildcard’s address has to stay ours for as long as the wildcard exists: OWASP’s cheat sheet lists A records pointing at released cloud addresses as a takeover path, and if that address were ever released, every name the wildcard covers would follow it to the next holder.

Wildcards need a different inventory

Our zone deliberately has a wildcard pointing at our self-hosted deployment server for our own apps.

Live capture, 26 September 2026. A random label, the wildcard at Cloudflare’s authoritative server, and what the deployment server answers for a hostname it does not route:

$ dig @1.1.1.1 audit-probe-7k3m.and.guide A +noall +answer
;; (trimmed)
audit-probe-7k3m.and.guide. 300	IN	A	115.68.101.36
$ dig @roan.ns.cloudflare.com '*.and.guide' A +noall +answer
;; (trimmed)
*.and.guide.		300	IN	A	115.68.101.36
$ curl -sS -i --max-time 10 http://audit-probe-7k3m.and.guide/
HTTP/1.1 404 Not Found
Content-Type: text/plain; charset=utf-8
X-Content-Type-Options: nosniff
Date: Sat, 26 Sep 2026 14:45:10 GMT
Content-Length: 19

404 page not found

Over HTTPS, the same name gets the server’s self-signed default certificate, which curl rejects with error 60. Both responses fail closed. That is the half of OWASP’s wildcard advice a server can enforce: keep an allowlist of hostnames and return an error for everything else. The other half is to scope wildcards as narrowly as you can.

The audit consequence is simple. Behind a wildcard, every name resolves, so DNS cannot separate live names from stale ones. The inventory has to come from the deployment server’s routing table: list the hostnames it is configured to serve, then compare that list with the CT names. A CT name with no route is history; a route with no owner is a cleanup task.

Wildcards also have a boundary that surprises people. RFC 4592 synthesizes an answer only from a wildcard sitting directly under the closest existing ancestor of the queried name, and it never searches further up.

The one name with no answer in step 2 sits below another explicit record in our zone. The same rule is easy to see under www.

Live capture, 26 September 2026. An invented name under www, then www itself, at the authoritative server (15:18 UTC):

$ dig @roan.ns.cloudflare.com audit-probe-7k3m.www.and.guide A +noall +comments +authority | grep -E 'status|SOA'
;; ->>HEADER<<- opcode: QUERY, status: NXDOMAIN, id: 28888
and.guide.		1800	IN	SOA	frida.ns.cloudflare.com. dns.cloudflare.com. 2414193944 10000 2400 604800 1800
$ dig @roan.ns.cloudflare.com www.and.guide A +noall +answer
;; (trimmed)
www.and.guide.		300	IN	A	104.21.70.196
www.and.guide.		300	IN	A	172.67.138.236

www.and.guide exists, so it is the closest encloser for audit-probe-7k3m.www.and.guide; with no *.www.and.guide, nothing is synthesized and the answer is NXDOMAIN. Any name under an explicit record falls outside the zone-level wildcard’s cover. The wildcard DNS and TLS guide covers the certificate side of the same design.

Never aim a wildcard at a shared platform. GitHub strongly recommends against wildcard records for Pages because they expose you to takeover even when the domain is verified: verification covers the domain and its immediate subdomains, not the deeper names a wildcard also answers.

Platform protections worth enabling

Platform Protection What it prevents
GitHub Pages Verified domain via TXT at _github-pages-challenge-<account>.example.com Other accounts using the domain or its immediate subdomains; keep the TXT record in place
Azure App Service asuid.<subdomain> TXT holding your domain verification ID Other subscriptions validating your custom domain, even if they recreate the app name
Azure DNS Alias records for Front Door, Traffic Manager, CDN endpoints, and public IPs A record set outliving its resource: it empties when the resource is deleted
Microsoft Defender for App Service Dangling DNS detection A decommissioned App Service site whose domain still points at it going unnoticed
Azure classic cloud services Name reservation after deletion Immediate reuse: only your tenant can reclaim the cloudapp.net name for a period, then anyone can
Cloudflare Pages Documented removal order Deleting a project while its CNAME still points at it

None of these replaces the removal order. They limit the damage when it is skipped, and only on their own platform.

A recurring audit checklist

OWASP suggests automated fingerprint scanning at least weekly. Run the full list below on a schedule you will keep, and again after any migration, platform change, or wildcard change:

  1. Build the name list from three sources: a zone export, every platform’s custom-domain list, and CT logs.
  2. Resolve every name and group by target, as in step 2 above.
  3. For each CNAME to a platform, confirm the target exists and is yours; flag NXDOMAIN targets and “nothing deployed” fingerprints.
  4. For each wildcard, compare the target server’s routing table with the CT list.
  5. For each NS delegation, query the delegated servers directly; a REFUSED or SERVFAIL for the child zone means the account behind it needs checking now.
  6. For each MX, confirm the mail service still exists and belongs to you.
  7. Record an owner and a review date for every name that stays, in the same place as the rest of your public hostname inventory.
  8. Review new CT entries since the last run for names nobody requested.

An audit that finds nothing claimable, like ours, is still worth repeating: the risk comes back with every resource you delete.