DNSSEC Validation in Go: I Wrote My Own Validator and Didn't Go Completely Crazy

I'm building VantageDNS, a privacy-focused recursive DNS resolver with filtering. The edge frontend is written in Go, has 10 nodes worldwide, and uses Miek's miekg/dns library under the hood. Eventually I ran out of excuses and had to write a DNSSEC validator. With my own two hands. At night. On my eighth cup of coffee.

Below I'll explain how the trust chain is structured, what's available in the standard library, what pitfalls are laid out along the way, and why I still avoid algorithm 14 like a cat avoids a yard. At the end there are links to an open-source implementation, you can tinker with it.

Why DNSSEC at all

In short, DNSSEC solves two tasks. The first is protection against cache poisoning. The Kaminsky attack, remember? It turned 17 years old in 2026, and there are no fewer surprises: every time there's another "BGP incident redirected traffic" in the news, right nearby there is always a resolver without validation. The second task is origin verification: you make sure the response is actually signed by the zone owner, not some guy in a café with an OpenWrt router and malicious intentions.

In practice, based on my measurements on edge nodes, about 95% of domains in the .com/.net TLDs still don't have RRSIG records. Mostly large services, banks, government services, and paranoids have them signed. But DNSSEC is really needed for critical scenarios. And if you're building a resolver seriously, you need to validate.

At first I tried to ignore it. As usual, reality beat me over the head with a stick.

Trust chain on one page

I'll explain it briefly so the code later is understandable.

DNSSEC is built as a hierarchy of signatures from the requested record up to the root zone. At each level there are three key entities.

DNSKEY are the zone's public keys. There are two types: KSK (Key Signing Key, flag 257) only signs the DNSKEY rrset. ZSK (Zone Signing Key, flag 256) signs the rest of the RRsets.

RRSIG is the signature itself. Each RRSet in a signed zone has its own RRSIG.

DS (Delegation Signer) is stored by the parent. It is a hash of the child's KSK. The parent says: "this key of my child is genuine".

Validation = moving from bottom to top:

  1. Received A example.com and RRSIG A example.com.

  2. Requested DNSKEY example.com, found ZSK with the required KeyTag, verified the signature.

  3. Requested the full DNSKEY example.com as an rrset, verified its KSK signature.

  4. Requested DS example.com from .com, verified that the KSK hash matches.

  5. Next, we validate the RRSIG for DS at .com via the DNSKEY at .com, and so on up to the root.

  6. The root DNSKEY is verified against the trust anchor, the IANA public key that you hardcoded into your code or pulled from a file.

The trust anchor is the only point where trust comes from "outside". Everything else is cryptography.

What's in miekg/dns

The github.com/miekg/dns library is the foundation. It parses wire-format, calculates KeyTag, implements RRSIG.Verify() for all live algorithms. I love it like people love an old cat: for what it is, and it doesn't require explanations.

But the validation algorithm itself is not included in it. And that's correct. Security-critical logic should be in your code, so you understand where it fails, instead of praying to a black box. miekg/dns provides primitives, you have to assemble the chain yourself.

Step-by-step algorithm

First, validation of a single RRSet. This is the base on which everything else is built.

func (r *Resolver) ValidateRRSet(rrset []dns.RR, rrsigs []*dns.RRSIG, keyset []*dns.DNSKEY) error { if len(rrsigs) == 0 { return ErrNoSignature } now := time.Now().Unix()

for _, rrsig := range rrsigs {
    if int64(rrsig.Inception) > now || int64(rrsig.Expiration) < now {
        continue
    }
    for _, k := range keyset {
        if k.KeyTag() != rrsig.KeyTag {
            continue
        }
        if k.Algorithm != rrsig.Algorithm {
            continue
        }
        if err := rrsig.Verify(k, rrset); err == nil {
            return nil
        }
    }
}
return ErrNoValidSig

}

Next is recursive traversal up the chain. The idea is simple: for zone Z, we check the DNSKEY rrset (KSK signs the DNSKEY, ZSK signs everything else), then request the DS from the parent and confirm that the child's KSK matches the DS.

Full ValidateChain function (simplified version)

func (r *Resolver) ValidateChain(ctx context.Context, name string, qtype uint16) error { rrset, rrsigs, err := r.fetchSigned(ctx, name, qtype) if err != nil { return err }

zone := signerName(rrsigs)
if zone == "" {
    return ErrNoSigner
}

keyset, keysigs, err := r.fetchSigned(ctx, zone, dns.TypeDNSKEY)
if err != nil {
    return err
}
keys := toDNSKEY(keyset)

if err := r.ValidateRRSet(rrset, rrsigs, keys); err != nil {
    return fmt.Errorf("rrset %s: %w", name, err)
}
if err := r.ValidateRRSet(keyset, keysigs, keys); err != nil {
    return fmt.Errorf("dnskey %s: %w", zone, err)
}

if zone == "." {
    return r.matchTrustAnchor(keys)
}

parent := dns.Fqdn(strings.SplitN(zone, ".", 2)[1])
dsset, dssigs, err := r.fetchSigned(ctx, zone, dns.TypeDS)
if err != nil {
    return err
}

if err := r.matchDS(keys, dsset); err != nil {
    return err
}

parentKeys, _, err := r.cachedKeys(ctx, parent)
if err != nil {
    return err
}
if err := r.ValidateRRSet(dsset, dssigs, parentKeys); err != nil {
    return fmt.Errorf("ds %s: %w", zone, err)
}

return r.ValidateChain(ctx, parent, dns.TypeDNSKEY)

}

The actual version in the repository is longer: it includes caching of intermediate DNSKEY/DS, handling CNAME (when the zone changes, the chain follows a new branch), and backoff for network errors. But the core is like this.

I keep the root DS as a hardcoded structure with the ability to override it via a file. RFC 5011 is currently simplified, more on that later.

Rakes I Gathered

Algorithms

As of 2026, the live algorithms for DNSSEC are as follows.

Algorithm 8, RSASHA256. An old workhorse, still used by the majority of signed zones.

Algorithm 13, ECDSA P-256 with SHA-256. The modern default, already used by the root.

Algorithm 15, Ed25519. Growing, but slowly.

Algorithm 14 (ECDSA P-384) is encountered in the wild less often than a falling meteorite. I support it formally, but if you're short on time, you can safely start without it. RSAMD5 and RSASHA1 should be rejected as insecure.

Time Skew

RRSIG has Inception and Expiration fields in Unix seconds. If an edge node's NTP drifts more than a couple of minutes, validation breaks, and silently: you see ErrNoValidSig and think the zone is broken.

A real story: one of my nodes with a broken systemd-timesyncd spent a week failing DNSSEC validation for some users. I noticed when I received a ticket saying, "my bank won't open, but my neighbor's will." I checked and found that timedatectl showed "System clock synchronized: no," and the clock was 12 minutes ahead.

Since then, my healthcheck includes a check for skew against two independent NTP sources, and I have a dnssec_validation_clock_skew_seconds metric in Prometheus. I test it on myself without flags, which is quite humiliating, but it works.

NSEC and NSEC3

Separate story, negative responses. "Domain does not exist" also has to be proven cryptographically, otherwise an attacker will just say "there is no such record" and bypass validation.

NSEC is a linked list of names in the zone, sorted canonically. The zone signs "there is nothing between aaa.example.com and ccc.example.com", and you verify that bbb.example.com actually falls into this interval.

NSEC3 is structured the same way, but with hashed names, so that you can't enumerate all domains in the zone via zone walking. Implementing jumps across the hash space is a separate quest that takes a couple of evenings. I honestly postponed full NSEC3 validation to v2; in the first version I validate NSEC, and for NSEC3 I fall back to insecure.

Loose validators and broken chains

In reality, a lot of zones are signed incorrectly. The most common case: the main zone is signed, it has a CNAME to a third-party domain, and there are no signatures there. Formally, according to RFC, you have to return SERVFAIL.

In practice, if you are a resolver for end users, SERVFAIL means "my internet isn't working", and the user will switch to 8.8.8.8. So I made a permissive mode: when there is a broken chain, I log it and return an insecure response with a note in the query log. Paranoids can stick with strict mode in their profile.

Trust anchor rotation

ICANN rotated the root KSK in 2018, and at that time many resolvers went down because their operators forgot to update the trust anchor. Since then there has been RFC 5011, automated rollover: the resolver itself picks up the new KSK if it is signed with the old one, and after the hold-down period trusts the new one.

In the first version I didn't implement RFC 5011 at all, I just had a trust-anchors.xml file (the same one published by IANA) that is pulled during updates of the edge binary. This works as long as you release updates more frequently than ICANN rotates keys. Which is always. But for serious deployment 5011 needs to be finished, and I will finish it as soon as the current sprint is closed.

What I didn't do in the first version and regretted

Caching negative validations. I re-validated every NXDOMAIN, re-parsed all NSEC/NSEC3 records, and re-checked all signatures. On the traffic stream of a typical edge node this consumed a noticeable amount of CPU and added latency for "non-existent" domains, of which, as is known, there are very many in the wild (all kinds of malware DGA, typos, telemetry from failed services).

This is fixed with a separate negative cache, where the key is (qname, qtype, NSEC-proof-hash). I implemented it in the second iteration, and latency for NXDOMAIN dropped several times.

The second mistake was not optimizing crypto. One RRSet can have 5-10 RRSIGs (the zone uses multiple keys or changes the algorithm). I was checking them sequentially, verifying all of them. With a large DNSKEY RRSet and RSA, this is noticeable. The solution is to first filter by KeyTag/Algorithm, then by time, and only then perform the expensive verification.

Enable by default

My DNSSEC validation is opt-in: the profile has dnssec_validation: true. On the free plan, it's disabled by default for two reasons.

First, most users on the free plan don't bother. They just need "ads are not shown, and that's fine." If something starts SERVFAILing, they'll complain to support, but they don't have the time or desire to deal with a broken chain.

Second, I still encounter edge cases once a week. Someone complains that a certain regional site doesn't resolve. You go to check, and their KSK has algorithm 14 and the expiration date was yesterday. The zone operator is asleep. You can't do anything.

On the paid plan, I plan to make it default-on in 6 months, once I've cataloged all these cases and learned either to work around them or to explain "your DNSSEC is broken, go to your admin."

How to test

There are several public test zones:

  • dnssec-failed.org, specifically with a broken signature, should SERVFAIL.

  • dnssec-tools.org, correctly signed, should resolve.

  • internetsociety.org, a large real site with DNSSEC, good for a smoke test.

dig +dnssec @dns.vantagedns.com/ dnssec-failed.org dig +dnssec @dns.vantagedns.com/ dnssec-tools.org A

In the first case, we expect status: SERVFAIL. In the second, status: NOERROR and the ad flag (Authenticated Data) in the response. If there's no ad flag, validation didn't work, go check the logs.

In unit tests, I use hardcoded wire-format responses from testdata/, generated via dig +dnssec +noall +answer and saved. This way, tests don't depend on the external network and don't fail when the test zone's signatures expire.

Where is the code

I'm preparing my DNSSEC module for release, but the repository is currently private — it's next to blocklist-engine and shipper, which haven't yet been separated into clean boundaries, as they contain operational specifics for my 10 nodes. If you'd like to take a look at a specific fragment (validator, trust-anchor loader, NSEC3 walk) for your system, please write to [email protected], and I'll send a tarball with tests. I'll be especially grateful for feedback on NSEC3 and algorithm 14, as I've honestly admitted that these are weak areas.

What I've Learned

DNSSEC is more complex than it seems from Wikipedia, and simpler than it's portrayed on conferences. If you're building your own resolver, don't ignore it, but don't write a validator in the first month either. First, focus on a normal cache, negative responses, ECS, and filtering. Once the basic part works and doesn't crash under load, then DNSSEC. By that time, you'll have infrastructure for tests, metrics, and alerts, without which a validator will turn into a black box with a mood.

And please, set up a proper NTP on all nodes. I'm serious.

Links

Comments

    Also read