If you’re like me, you’ve had the experience where you add a DNS record for a service. You triple check it. The app still says it’s invalid. You wait. You check again. Still invalid. You start wondering if you did something wrong.

DNS ownership verification failed dialog

You didn’t. The app is just checking DNS in a half-assed way.

If you’re building a product that asks users to add DNS records, please take the time to verify DNS records properly.

Sometimes, users have to deal with DNS

Custom domains, email deliverability, domain ownership verification, and even Bluesky handles require users to add DNS records.

For example, if your product hosts web sites on custom domains, you might ask a user to add a CNAME record for example.com with a value of hosting.tld.

This routes HTTP requests for https://example.com to https://hosting.tld, which can read the Host header and serve the appropriate site.

Surprisingly, users can add DNS records

Anyone who has asked users to add DNS records might be surprised to learn that many non-technical users can do it correctly with good instructions.

At the same time, even technical users sometimes struggle. Who will get stuck often depends on the quality of their provider’s DNS management interface.

Build a DNS verification feature

If your app asks users to add DNS records, give them a way to check for correctness.

A “verify DNS” button that tells the user immediately whether their record is correct or what’s wrong.

The user adds the record, clicks verify, and gets a definitive answer without having to wait for tech support to check and respond.

This is one of those features that has a huge payoff. It reduces tech support load and makes the whole experience less frustrating for everyone.

Bad solution: ask the cache

The simplest way to verify a DNS record is with a high level lookup function. In Go, that looks like this:

cname, err := net.LookupCNAME("example.com")
if err != nil {
	return fmt.Errorf("lookup failed: %w", err)
}
if cname != "hosting.tld." {
	return fmt.Errorf("expected hosting.tld., got %s", cname)
}

This queries your server’s configured resolver, which almost certainly caches results. DNS records have a TTL (Time To Live) that specifies how long resolvers should cache them, but resolvers often ignore TTLs or enforce their own minimums. Even when TTLs are respected, the user’s record might have a TTL of 5 or 20 minutes. That’s a long time to make the user wait.

So the user adds their record, clicks verify, and your app checks the cache. The cache still has the old answer, or worse, a cached “not found”. You tell the user it’s wrong. They double check. It looks right. They try again five minutes later. Still wrong. Now they’re frustrated and confused, second guessing themselves, when the record has been correct the whole time.

This is what some apps do and it actively makes the problem harder to debug.

Good solution: ask the authority

Instead of asking a cache, you can query the authoritative nameservers directly. These are the servers that actually hold the zone’s records.

At a high level, the code should look something like this:

nameservers := FindNameservers("example.com")
for _, ns := range nameservers {
    values := QueryServer(ns, "example.com.", dns.TypeCNAME)
    VerifyRecords(values, "hosting.tld")
}

Find the nameservers

Query for NS records, walking up the domain tree until you find the zone’s nameservers:

func FindNameservers(fqdn string) ([]string, error) {
	domain := fqdn
	for {
		msg := new(dns.Msg)
		msg.SetQuestion(domain, dns.TypeNS)
		msg.RecursionDesired = true

		response, err := dns.Exchange(msg, "8.8.8.8:53")
		if err != nil {
			return nil, fmt.Errorf("NS lookup for %s: %w", domain, err)
		}

		var servers []string
		for _, record := range response.Answer {
			if ns, ok := record.(*dns.NS); ok {
				servers = append(servers, ns.Ns)
			}
		}
		if len(servers) > 0 {
			return servers, nil
		}

		index := strings.Index(domain, ".")
		if index < 0 {
			break
		}
		next := domain[index+1:]
		if next == "" || next == "." {
			break
		}
		domain = next
	}

	return nil, fmt.Errorf("no nameservers found for %s", fqdn)
}

This starts with the full domain (e.g. sub.example.com.) and tries each parent zone until it finds one with NS records. Most domains will match on the first or second try.

Query each nameserver directly

Resolve each NS hostname to an IP and send your query directly. Since you’re asking the authoritative server, it answers from its own zone data, not a cache.

func QueryServer(server string, fqdn string, recordType uint16) ([]string, error) {
	msg := new(dns.Msg)
	msg.SetQuestion(fqdn, recordType)
	// Some nameservers return empty answers for non-recursive queries.
	msg.RecursionDesired = true

	address := net.JoinHostPort(server, "53")
	response, err := dns.Exchange(msg, address)
	if err != nil {
		return nil, err
	}

	var values []string
	for _, record := range response.Answer {
		switch r := record.(type) {
		case *dns.A:
			values = append(values, r.A.String())
		case *dns.AAAA:
			values = append(values, r.AAAA.String())
		case *dns.CNAME:
			values = append(values, r.Target)
		case *dns.TXT:
			values = append(values, strings.Join(r.Txt, ""))
		case *dns.MX:
			values = append(values, r.Mx)
		}
	}
	return values, nil
}

Verify the response

This is where many implementations get it wrong. It’s not enough to check that the expected value is present in the response. You also need to check that it’s the only value.

func VerifyRecords(values []string, expected string) (bool, string) {
	if len(values) == 0 {
		return false, "no records found"
	}

	if len(values) > 1 {
		return false, fmt.Sprintf("expected 1 record, got %d: %v", len(values), values)
	}

	got := strings.TrimSuffix(values[0], ".")
	want := strings.TrimSuffix(expected, ".")
	if !strings.EqualFold(got, want) {
		return false, fmt.Sprintf("expected %s, got %s", want, got)
	}

	return true, ""
}

Watch out for extra records. Users sometimes add the correct record and leave an old one in place, or accidentally create duplicates. For example, a user adding an A record with your IP might not realize they already have an A record pointing somewhere else. The nameserver will return both values. If you only check that your expected value is present, you’ll tell the user everything looks good, but traffic will be split between two IPs and their site will break intermittently. Always verify that the response contains exactly the records you expect, nothing more.

Instant feedback

Authoritative verification gives users immediate, accurate results. They know right away if their record is correct, with no waiting for caches to expire and no guessing.

If you ask users to add DNS records, check them at the source, please!

I put together a small Go library and CLI tool called addled that wraps up the approach described in this post.

Find me on Bluesky

I am @jacob.gold on Bluesky.