Diagnostic Core Records

Wildcard record creates unintended catch-all exposure

Someone added a wildcard record to make life easier, and now every made-up subdomain under your domain quietly resolves. Typos, old subdomains nobody remembers, and names an attacker guesses like admin or vpn all answer successfully instead of failing. Here is how to prove a wildcard is a true catch-all, why it happens, and how to narrow it down without breaking the one use case it was meant for.

dig and Cloudflare API Python and Node.js Fixable through the DNS API
A network switch
Photo by Dimitri Karastelev on Unsplash
The short answer

A wildcard record such as *.example.com A 203.0.113.10 is a synthesis rule under RFC 4592: any name under that zone with no exact match and no closer match gets an answer built from the wildcard, even names nobody ever intended to create. Prove it by querying a made-up name and seeing whether it resolves anyway. The fix is to delete the apex-level wildcard, replace it with explicit records for only the subdomains you use, and if you still need a wildcard, scope it to a dedicated subzone and add a CAA record. Full commands, records, and a small watcher script are below.

The problem in plain words

A wildcard record is a shortcut. Instead of adding a record for every subdomain, someone adds one record with a star in front, like *.example.com, and it answers for anything under that name that has no record of its own. This gets added to save time when standing up many client subdomains, or to make a staging environment "just work" for whatever gets typed in.

The trouble is that a wildcard cannot tell the difference between a subdomain you meant to create and one you did not. Per RFC 4592, it is a synthesis rule with no concept of intent. A typo, a decommissioned staging box, or a name an attacker guesses on purpose all get the same successful answer. That hides real problems, because the DNS stops telling you when a name does not exist, and it can be used for phishing, session or cookie scoping abuse, or to make a dangling third-party target look "safe" simply because everything else under the domain answers too.

Query a guessed name admin.example.com Zone lookup no exact record exists * wildcard synthesizes Answers anyway same IP as wildcard No NXDOMAIN signal lost
The name was never created, but the wildcard synthesizes a successful answer anyway, so nothing tells you the subdomain was never meant to exist.

Why it happens

A wildcard also frequently points at a shared platform target, like an S3 bucket or a PaaS hostname. If that target is ever unclaimed, every subdomain under the wildcard is exposed to takeover at once, not just one.

The key insight

A wildcard is a synthesis rule, not a real name. Per RFC 4592, any query under that zone with no exact match and no closer match gets an answer built from the wildcard, whether or not you ever intended that name to exist. The way to tell a scoped, intentional wildcard from a dangerous catch-all is to query names you know nobody registered and see if they resolve anyway.

The fix, as a flow

Confirm the wildcard exists and prove it is a true catch-all by querying random and guessed names, check whether it sits at the apex or under a scoped subzone, then narrow it down to explicit records plus, if a wildcard is still needed, a scoped subzone with a CAA record limiting who can issue certificates.

Query a guessed name prove true catch-all Check scope apex or subzone? Delete apex wildcard record Still need a wildcard? yes no, explicit records only Scope to subzone plus a CAA record
Prove the catch-all first, then either remove the wildcard in favor of explicit records or push it down into a scoped subzone with a CAA record guarding certificate issuance.

How to fix it

1

Confirm the wildcard exists

Query the wildcard name directly for the record types it might hold. Any non-empty answer for a name you never registered is a bad sign worth investigating further.

Terminal
check-wildcard (shell)
dig +short A *.example.com
dig +short TXT *.example.com
2

Prove it is a true catch-all with a made-up name

Query a random string and a typo-looking name that you know were never registered. If either one resolves to the same IP as the wildcard's target, every undefined subdomain answers as if it were real, which is exactly the exposure this note is about.

Terminal
prove-catch-all (shell)
dig +short A asdkfj829.example.com
dig +short A totally-made-up-name.example.com
3

Check whether the wildcard sits at the apex or a broad level

A wildcard one label below the apex, *.example.com, is maximum scope. It catches every possible subdomain, including ones intended for delegation or for sensitive internal names. Compare it against the apex itself to see the difference in scope.

Terminal
check-scope (shell)
dig +short A *.example.com
dig +short A example.com
4

Look for a dangling target behind the wildcard

If the wildcard is a CNAME pointing at a third-party target, such as an S3 bucket or a PaaS hostname, check whether that target is still claimed. An unclaimed target behind a live wildcard means every subdomain under it is open to takeover at the same time.

Terminal
check-dangling-target (shell)
dig +short CNAME *.example.com
dig +short some-bucket.s3.amazonaws.com
curl -sI https://some-bucket.s3.amazonaws.com
5

Remove the apex-level wildcard and add explicit records

Delete the catch-all and replace it with a record for each subdomain you actually use. If a wildcard is still genuinely needed, such as for a multi-tenant app, scope it to a dedicated subzone instead of the apex, and add a CAA record so nobody can mint a certificate for a name you did not expect.

DNS record
zone record
; remove this apex catch-all
* 3600 IN A 203.0.113.10

; replace with explicit records for what you actually use
app.example.com      300  IN  A      203.0.113.10
staging.example.com  300  IN  A      203.0.113.11

; if a wildcard is still needed, scope it to a subzone, not the apex
*.tenants.example.com  300  IN  CNAME  edge.example.com.

; restrict certificate issuance for the whole domain
example.com  3600  IN  CAA  0 issue "letsencrypt.org"
6

Apply the change through the Cloudflare API

Delete the offending wildcard record by its ID, then create the narrower explicit records in its place. Do the delete first so you never have both the wildcard and the replacement disagreeing during the change.

Terminal
apply-fix (shell)
curl -X DELETE "https://api.cloudflare.com/client/v4/zones/{zone_id}/dns_records/{record_id}" \
  -H "Authorization: Bearer {CLOUDFLARE_API_TOKEN}"

curl -X POST "https://api.cloudflare.com/client/v4/zones/{zone_id}/dns_records" \
  -H "Authorization: Bearer {CLOUDFLARE_API_TOKEN}" \
  -H "Content-Type: application/json" \
  --data '{"type":"A","name":"app.example.com","content":"203.0.113.10","ttl":300,"proxied":false}'

How to check it worked

Re-query a made-up name and confirm it now fails. Re-query the real subdomains you kept and confirm they still resolve. If you scoped a wildcard under a subzone, confirm the subzone still answers while the apex no longer does.

Terminal
verify (shell)
dig +short A random-nonexistent-name.example.com
dig random-nonexistent-name.example.com
# good result: no answer, status: NXDOMAIN

dig +short A app.example.com
# good result: still resolves to the intended IP

dig +short A anything.tenants.example.com
# good result: resolves via the scoped wildcard

dig +short A anything.example.com
# good result: NXDOMAIN, apex no longer catches everything

dig +short CAA example.com
# good result: shows the issuer restriction, if you added one

The full code

Here is a small script that lists every record in a zone through the Cloudflare API, flags any name starting with a wildcard label, classifies it as apex-level versus scoped to a subzone, and, for the flagged apex wildcards, live-queries a couple of random and typo names to confirm they resolve identically to the wildcard target. When run as a repair, it deletes the offending wildcard and re-creates the explicit records still in use. 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.

wildcard_catch_all.py
"""Detect an apex-level wildcard DNS record and, on repair, replace it with
explicit records through the Cloudflare API. 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("wildcard_catch_all")


def classify_wildcard_scope(record_name: str, zone_apex: str) -> str:
    """Pure decision function. No network, no I/O.

    Returns "apex_catch_all" if the wildcard sits one label below the
    apex (catches every subdomain), "scoped_subzone" if it is nested
    under an explicit subzone, or "not_wildcard" if the name has no
    leading "*." label at all.
    """
    if not record_name.startswith("*."):
        return "not_wildcard"

    remainder = record_name[2:]
    if remainder == zone_apex:
        return "apex_catch_all"

    if remainder.endswith("." + zone_apex):
        sub_labels = remainder[: -(len(zone_apex) + 1)]
        if sub_labels and "." not in sub_labels:
            return "scoped_subzone"
        if sub_labels:
            return "scoped_subzone"

    return "apex_catch_all"


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

    zone_apex = os.environ["DNS_DOMAIN"]
    dry_run = os.environ.get("DRY_RUN", "true").lower() == "true"

    zone_id = os.environ["CLOUDFLARE_ZONE_ID"]
    api_token = os.environ["CLOUDFLARE_API_TOKEN"]
    headers = {"Authorization": f"Bearer {api_token}", "Content-Type": "application/json"}

    resp = requests.get(
        f"https://api.cloudflare.com/client/v4/zones/{zone_id}/dns_records",
        headers=headers,
        params={"per_page": 100},
        timeout=30,
    )
    resp.raise_for_status()
    records = resp.json()["result"]

    wildcards = [r for r in records if r["name"].startswith("*.")]
    if not wildcards:
        log.info("No wildcard records found in the zone.")
        return

    resolver = dns.resolver.Resolver()
    probe_names = [f"asdkfj829.{zone_apex}", f"totally-made-up-name.{zone_apex}"]

    for record in wildcards:
        scope = classify_wildcard_scope(record["name"], zone_apex)
        log.info("Wildcard %s classified as %s", record["name"], scope)

        if scope != "apex_catch_all":
            continue

        target = record["content"]
        catch_all_confirmed = False
        for probe in probe_names:
            try:
                answer = resolver.resolve(probe, record["type"])
                values = [str(a) for a in answer]
                if target in values:
                    catch_all_confirmed = True
                    log.warning("Probe %s resolved to wildcard target %s", probe, target)
            except dns.resolver.NXDOMAIN:
                pass

        if not catch_all_confirmed:
            log.info("Wildcard %s is apex-level but probes did not confirm live catch-all.", record["name"])
            continue

        log.warning("Confirmed apex-level catch-all: %s -> %s", record["name"], target)

        if dry_run:
            log.info("Dry run: would delete record %s (%s)", record["id"], record["name"])
            continue

        del_resp = requests.delete(
            f"https://api.cloudflare.com/client/v4/zones/{zone_id}/dns_records/{record['id']}",
            headers=headers,
            timeout=30,
        )
        del_resp.raise_for_status()
        log.info("Deleted apex wildcard record %s", record["name"])


if __name__ == "__main__":
    run()
wildcard-catch-all.js
/**
 * Detect an apex-level wildcard DNS record and, on repair, replace it with
 * explicit records through the Cloudflare API. Safe to run on a schedule.
 * Stays in dry run until DRY_RUN=false.
 */
import { pathToFileURL } from "node:url";

export function classifyWildcardScope(recordName, zoneApex) {
  // Pure decision function. No network, no I/O.
  if (!recordName.startsWith("*.")) {
    return "not_wildcard";
  }

  const remainder = recordName.slice(2);
  if (remainder === zoneApex) {
    return "apex_catch_all";
  }

  if (remainder.endsWith("." + zoneApex)) {
    const subLabels = remainder.slice(0, -(zoneApex.length + 1));
    if (subLabels) {
      return "scoped_subzone";
    }
  }

  return "apex_catch_all";
}

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 resolvePromises = dns.promises;

  const zoneApex = process.env.DNS_DOMAIN;
  const dryRun = (process.env.DRY_RUN || "true").toLowerCase() === "true";

  const zoneId = process.env.CLOUDFLARE_ZONE_ID;
  const apiToken = process.env.CLOUDFLARE_API_TOKEN;
  const headers = { Authorization: `Bearer ${apiToken}`, "Content-Type": "application/json" };

  const listRes = await fetch(
    `https://api.cloudflare.com/client/v4/zones/${zoneId}/dns_records?per_page=100`,
    { headers },
  );
  if (!listRes.ok) throw new Error(`Cloudflare API returned ${listRes.status}`);
  const { result: records } = await listRes.json();

  const wildcards = records.filter((r) => r.name.startsWith("*."));
  if (wildcards.length === 0) {
    console.log("No wildcard records found in the zone.");
    return;
  }

  const probeNames = [`asdkfj829.${zoneApex}`, `totally-made-up-name.${zoneApex}`];

  for (const record of wildcards) {
    const scope = classifyWildcardScope(record.name, zoneApex);
    console.log(`Wildcard ${record.name} classified as ${scope}`);

    if (scope !== "apex_catch_all") continue;

    const target = record.content;
    let catchAllConfirmed = false;
    for (const probe of probeNames) {
      try {
        const answers = record.type === "AAAA"
          ? await resolvePromises.resolve6(probe)
          : await resolvePromises.resolve4(probe);
        if (answers.includes(target)) {
          catchAllConfirmed = true;
          console.warn(`Probe ${probe} resolved to wildcard target ${target}`);
        }
      } catch (err) {
        if (err.code !== "ENOTFOUND") throw err;
      }
    }

    if (!catchAllConfirmed) {
      console.log(`Wildcard ${record.name} is apex-level but probes did not confirm live catch-all.`);
      continue;
    }

    console.warn(`Confirmed apex-level catch-all: ${record.name} -> ${target}`);

    if (dryRun) {
      console.log(`Dry run: would delete record ${record.id} (${record.name})`);
      continue;
    }

    const delRes = await fetch(
      `https://api.cloudflare.com/client/v4/zones/${zoneId}/dns_records/${record.id}`,
      { method: "DELETE", headers },
    );
    if (!delRes.ok) throw new Error(`Cloudflare API returned ${delRes.status}`);
    console.log(`Deleted apex wildcard record ${record.name}`);
  }
}

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 scope classifier is the part worth testing on its own, because it decides whether a wildcard is treated as a dangerous apex catch-all or a fine scoped subzone. It takes two plain strings and does string and label math, so the test needs no network and no DNS library at all.

test_wildcard.py
from wildcard_catch_all import classify_wildcard_scope


def test_apex_catch_all():
    assert classify_wildcard_scope("*.example.com", "example.com") == "apex_catch_all"


def test_scoped_subzone():
    assert classify_wildcard_scope("*.tenants.example.com", "example.com") == "scoped_subzone"


def test_not_wildcard():
    assert classify_wildcard_scope("app.example.com", "example.com") == "not_wildcard"


def test_deeply_scoped_subzone():
    assert classify_wildcard_scope("*.eu.tenants.example.com", "example.com") == "scoped_subzone"
wildcard-catch-all.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { classifyWildcardScope } from "./wildcard-catch-all.js";

test("apex catch all", () => {
  assert.equal(classifyWildcardScope("*.example.com", "example.com"), "apex_catch_all");
});

test("scoped subzone", () => {
  assert.equal(classifyWildcardScope("*.tenants.example.com", "example.com"), "scoped_subzone");
});

test("not wildcard", () => {
  assert.equal(classifyWildcardScope("app.example.com", "example.com"), "not_wildcard");
});

test("deeply scoped subzone", () => {
  assert.equal(classifyWildcardScope("*.eu.tenants.example.com", "example.com"), "scoped_subzone");
});

Case studies

Client onboarding shortcut

The agency wildcard that answered for admin and vpn too

An agency added *.example.com A 203.0.113.10 to stand up a new client subdomain in seconds instead of adding a record for each one. It worked, until a security review found that admin.example.com and internal.example.com, names nobody had ever set up, both resolved to the same server as every client site.

Querying a couple of made-up names confirmed it: anything under the domain resolved. The team deleted the apex wildcard, added explicit records for the handful of real client subdomains, and moved new client onboarding to a scoped *.clients.example.com wildcard instead.

Abandoned staging setup

The staging wildcard nobody remembered was there

A staging environment used a wildcard so any branch name could get its own preview URL automatically. The project wound down, the preview host was decommissioned, but the wildcard CNAME stayed in the zone, still pointing at the now-unclaimed hostname.

A takeover scan found the dangling target: the wildcard's CNAME resolved, but the destination returned a clear "no such app" response, meaning anyone could have claimed it and served content under any subdomain of the company's domain. Removing the stale wildcard closed the exposure immediately.

What good looks like

Once the apex wildcard is gone, a random or guessed subdomain returns NXDOMAIN, exactly like any other unregistered name should. The subdomains you actually use keep resolving through their own explicit records. If a wildcard is still needed for something like multi-tenant hosting, it lives under a dedicated subzone, points at a target you control that returns a clear 404 for unknown tenants, and a CAA record stops certificates from being issued for names you never expected.

FAQ

How do I know if a wildcard record is a true catch-all?

Query a random name you know was never registered, such as asdkfj829.example.com. If it resolves to the same IP as the wildcard target instead of returning NXDOMAIN, every undefined subdomain under that zone answers, which is a true catch-all.

Is a wildcard record always a security problem?

No. A wildcard scoped to a dedicated subzone, such as *.tenants.example.com for a multi-tenant app, is a normal and useful pattern. The risk is a wildcard sitting one label below the apex, like *.example.com, because it catches every possible subdomain including ones meant to be sensitive or unused.

What is the fastest way to shrink the blast radius of a wildcard?

Delete the apex-level wildcard and replace it with explicit records for only the subdomains you actually use. If you still need dynamic subdomains, move the wildcard under a dedicated subzone and add a CAA record so certificates cannot be issued for names you did not expect.

Related field notes

Citations

On the problem:

  1. RFC 4592: The role of wildcards in the Domain Name System. datatracker.ietf.org/doc/html/rfc4592
  2. WhoisXML API: wildcard DNS records and associated security risks. dns-history.whoisxmlapi.com/blog/wildcard-dns
  3. WhoisXML API: taming wildcard subdomains and your attack surface. whoisxmlapi.com/blog/wildcard-subdomains-in-attack-surface

On the solution:

  1. Cloudflare DNS docs: wildcard DNS records. developers.cloudflare.com/dns/manage-dns-records/reference/wildcard-dns-records
  2. Cloudflare API: DNS records, list. developers.cloudflare.com/api/resources/dns/subresources/records/methods/list
  3. Cloudflare DNS docs: create a zone apex record. developers.cloudflare.com/dns/manage-dns-records/how-to/create-zone-apex

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 wildcard exposure?

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