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:
- You create a platform resource with its own hostname, such as
<app-name>.azurewebsites.net, and pointshop.example.comat it with a CNAME. - The resource is deleted. The CNAME stays, still advertising a name that no longer serves anything you own.
- Someone else creates a resource with the same platform hostname, and traffic for
shop.example.comnow 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 · 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:
- List every record that references the resource: CNAME, A/AAAA, MX, verification TXT records, and any NS delegation.
- If people still use the name, repoint it to something you run (a redirect or maintenance page) instead of leaving it on the platform.
- Delete or repoint the record at your DNS provider and note its TTL.
- Confirm the change at the authoritative servers, then wait at least one TTL so resolvers drop the old answer.
- Remove the custom-domain binding on the platform, then delete the resource.
- 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:
- Build the name list from three sources: a zone export, every platform’s custom-domain list, and CT logs.
- Resolve every name and group by target, as in step 2 above.
- For each CNAME to a platform, confirm the target exists and is yours; flag NXDOMAIN targets and “nothing deployed” fingerprints.
- For each wildcard, compare the target server’s routing table with the CT list.
- 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.
- For each MX, confirm the mail service still exists and belongs to you.
- Record an owner and a review date for every name that stays, in the same place as the rest of your public hostname inventory.
- 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.