DANE Explained: When DNS Vouches for the Certificate — No CA Required

This post is part of Protocol Lab, a free, hands-on series for learning networking protocols by building and breaking them in a container lab. All the lab material — topologies, configs, and scripts — lives in the repo: github.com/pathvector-studio/protocol-lab.

Normally a TLS client trusts a certificate because a Certificate Authority signed it (Lab 09). DANE offers a different anchor of trust: the domain owner publishes a TLSA record in DNS that pins the certificate, and DNSSEC (Lab 13) makes that record trustworthy. No CA required — DNS itself vouches for the cert.

Reading guide: rfc-notes/dane-tlsa.md

Prerequisites: DNS Lab 13: DNSSEC and TLS Lab 09: What Is Visible Before Encryption

Expected time: 55–70 minutes.

The Goal

This lab ties Lab 13 (DNSSEC) and Lab 09 (TLS certificates) together:

  • A DNSSEC-signed example.lab publishes a TLSA record for _443._tcp.www.example.lab that pins the web server's certificate.
  • The client fetches that TLSA (it carries an RRSIG, so it is trustworthy) and validates the server's self-signed cert against it → matched, Verify return code: 0.
  • An impostor server with a different cert is checked against the same TLSA → rejected: no matching DANE TLSA records.

By the end, you should be able to explain this table:

Server Cert Checked against the signed TLSA Result
real web (:443) self-signed, key pinned by TLSA matches Verify return code: 0 (ok)
impostor (:8443) different key does not match 65 (no matching DANE TLSA records)

What You Will Learn

  • What a TLSA record is, and how _port._proto.name names it.
  • What the 3 1 1 selector means (DANE-EE / SubjectPublicKeyInfo / SHA-256).
  • Why DANE only makes sense with DNSSEC — an unsigned TLSA could be forged.
  • How DANE lets a self-signed cert be trustworthy (the CA's job is done by DNS).
  • Why a cert that does not match the published TLSA is rejected, even if it looks valid.

This lab does not cover:

  • DANE for SMTP (RFC 7672) or other application profiles.
  • Certificate-usage modes other than 3 (DANE-EE), or selectors/matching other than 1 1.
  • Running a full DNSSEC-validating resolver in the client path (we read the RRSIG directly from the authoritative server; a validating resolver would set the AD flag as in Lab 13).

Where to Read in the RFCs

RFC Section What to focus on
RFC 6698 2 The structure of the TLSA record (usage / selector / matching type)
RFC 6698 3 The _port._proto.name naming scheme and how DANE is used
RFC 7671 4–5 Operational updates to DANE (e.g. the recommendation of DANE-EE 3)
RFC 4034 3 RRSIG (TLSA records get signed too — a Lab 13 refresher)
RFC 5737 3 Confirming the addresses used here are documentation-only

The Big Picture

Three nodes: one client, one auth server (serving the DNSSEC-signed example.lab zone, TLSA included), and one web server (a TLS server holding the certificate the TLSA pins).

auth (10.0.1.2) --- eth1 --- client --- eth2 --- web (10.0.2.2)
  BIND, signed             dig +dnssec TLSA      openssl s_server
  example.lab + TLSA       openssl s_client-dane :443 real / :8443 impostor

The client pulls the TLSA (with its RRSIG) from auth, then checks the web server's certificate against it. Port :443 serves the genuine cert the TLSA pins; :8443 serves an impostor with a different key.

sequenceDiagram
  participant C as client
  participant A as auth (DNSSEC-signed)
  participant W as web

  C->>A: _443._tcp.www.example.lab TLSA? (+dnssec)
  A-->>C: TLSA 3 1 1 <hash> + RRSIG
  Note over C: TLSA is signed -> trustworthy (Lab 13)
  C->>W: TLS to :443 (real cert)
  W-->>C: Certificate (self-signed)
  Note over C: SPKI hash == TLSA -> matched, Verify 0
  C->>W: TLS to :8443 (impostor cert)
  W-->>C: Certificate (different key)
  Note over C: hash != TLSA -> no matching DANE TLSA records

Note: 203.0.113.0/24 is not used here, and 10.0.1.0/24 / 10.0.2.0/24 are local, closed ranges — nothing in this lab touches the real internet.

What You Need

Recommended environment:

  • Linux / WSL2 / a Linux VM
  • Docker
  • containerlab

Images used:

  • protocol-lab/bind9:9.20 — built locally from examples/dane-17/Dockerfile, used for auth.
  • nicolaka/netshoot:latest — the client and the web server; it bundles dig and openssl.

Certificates and zone signing happen at run time via run.sh (generate the web cert → compute the TLSA → sign the zone). Nothing is committed to the repo. DANE verification uses openssl's -dane_tlsa_rrdata option.

Running the Lab

The quick path, which deploys, verifies, and tears down for you:

./scripts/labctl.sh run dane-17

labctl.sh run dane-17 handles everything: deploy, generating the web certificate and computing the TLSA, DNSSEC-signing the zone with the TLSA in it, fetching the TLSA, DANE-verifying the genuine cert (match), DANE-verifying the impostor (rejection), and cleanup.

Or step through it manually:

1. Move into the working directory

cd protocol-lab/examples/dane-17

2. Generate the web certificate and its TLSA

sudo containerlab deploy -t dane-17.clab.yml
# web: self-signed certs for the real server and the impostor
docker exec clab-dane-17-web sh -c \
  "openssl req -x509 -newkey rsa:2048 -nodes -keyout /tmp/real.key -out /tmp/real.crt -subj '/CN=www.example.lab' -addext 'subjectAltName=DNS:www.example.lab' -days 3650"
# TLSA (3 1 1) = DANE-EE / SPKI / SHA-256 of the real cert
docker exec clab-dane-17-web sh -c \
  "openssl x509 -in /tmp/real.crt -pubkey -noout | openssl pkey -pubin -outform DER | openssl dgst -sha256"
# TLS servers: the real cert on :443, the impostor on :8443
docker exec -d clab-dane-17-web sh -c "openssl s_server -accept 443 -cert /tmp/real.crt -key /tmp/real.key -www -quiet"

3. DNSSEC-sign the zone with the TLSA in it

Add _443._tcp.www.example.lab. IN TLSA 3 1 1 <hash> to example.lab, sign the zone as in Lab 13, and load it into auth (run.sh does this automatically).

_443._tcp.www.example.lab. 300 IN TLSA 3 1 1 <hash>
_443._tcp.www.example.lab. 300 IN RRSIG TLSA ...   <- DNSSEC signature

4. Fetch the TLSA (signed = trustworthy)

docker exec clab-dane-17-client dig +dnssec @10.0.1.2 _443._tcp.www.example.lab TLSA

You should see TLSA 3 1 1 ... with RRSIG TLSA ... right next to it. Because the RRSIG is there, this pin is protected by DNSSEC.

5. DANE-verify the genuine certificate (no CA involved)

RR=$(docker exec clab-dane-17-client dig +short @10.0.1.2 _443._tcp.www.example.lab TLSA | head -1)
docker exec clab-dane-17-client sh -c \
  "echo Q | openssl s_client -connect 10.0.2.2:443 -dane_tlsa_domain www.example.lab -dane_tlsa_rrdata '$RR'"

What to look for:

DANE TLSA 3 1 1 ...<hash> matched the EE certificate at depth 0
Verify return code: 0 (ok)

The certificate is self-signed — no CA anywhere — yet the result is Verify return code: 0, because DNS (backed by DNSSEC) vouches for it.

6. Check the impostor against the same TLSA (rejected)

docker exec clab-dane-17-client sh -c \
  "echo Q | openssl s_client -connect 10.0.2.2:8443 -dane_tlsa_domain www.example.lab -dane_tlsa_rrdata '$RR'"

What to look for:

Verification error: no matching DANE TLSA records
Verify return code: 65 (no matching DANE TLSA records)

A certificate with a different key doesn't match the published TLSA, so it is rejected.

Expected Output

  • dig +dnssec ... TLSA: TLSA 3 1 1 <hash> plus RRSIG TLSA.
  • The real server (:443): matched the EE certificate, Verify return code: 0 (ok).
  • The impostor (:8443): no matching DANE TLSA records, Verify return code: 65.

Why It Works

DANE (DNS-based Authentication of Named Entities) is a mechanism for declaring in DNS which certificate is the right one. It shifts the trust in a certificate away from a CA and onto the domain owner plus DNSSEC.

  • The TLSA record. A name is built from port, protocol, and hostname — _443._tcp.www.example.lab — and it pins the certificate that service uses. 3 1 1 means "usage = DANE-EE (the end-entity certificate itself) / selector = SPKI (the public key info) / matching = SHA-256." In other words: "trust only the certificate whose public key hashes to this SHA-256 value."
  • Why DNSSEC is mandatory. If the TLSA weren't signed, an attacker could inject a forged TLSA and pin a different certificate. Only once DNSSEC's RRSIG (Lab 13) guarantees the TLSA's authenticity does DANE become safe. That's why DANE is built on top of DNSSEC.
  • Why no CA is needed. The usual model is "a CA signs → the client trusts the CA." With DANE it's "the domain owner pins it in DNS → DNSSEC guarantees it." So even a self-signed cert is trustworthy as long as it matches the TLSA — DNS takes over the CA's role.
  • Why the impostor is rejected. Even if the impostor's certificate looks legitimate, or is signed by some other CA, it doesn't match the published TLSA (the fingerprint of the real key), so DANE rejects it. Certificate swapping is detected by a fingerprint anchored in DNS.

The key insight: putting a "this name uses this certificate" declaration in DNSSEC-protected DNS lets you authenticate certificates without a CA (or in addition to one).

Common Pitfalls

  • Talking about DANE without DNSSEC. An unsigned TLSA can be forged. DANE presupposes DNSSEC (Lab 13).
  • Getting the TLSA name wrong. It's _443._tcp.www.example.lab — port and protocol are prefixed.
  • What 3 1 1 means. usage = 3 (DANE-EE), selector = 1 (SPKI), matching = 1 (SHA-256).
  • Assuming self-signed means dangerous. In the DANE context, a self-signed cert is legitimate as long as the TLSA pins it.
  • Assuming the impostor would pass if it were signed by another CA. DANE checks exactly one thing: does the cert match the published fingerprint? The CA is irrelevant.
  • The AD flag. This lab queries the authoritative server directly, so AD is not set (the RRSIG still lets you confirm the signature). Going through a validating resolver would set AD, as in Lab 13.

Cleanup

sudo containerlab destroy -t dane-17.clab.yml --cleanup

If you used labctl.sh run dane-17, the script runs destroy for you at the end.

Check Your Understanding

  1. What does a TLSA record pin? What does each part of the name _443._tcp.www.example.lab represent?
  2. What do the three numbers in 3 1 1 each mean?
  3. Why does DANE presuppose DNSSEC? What goes wrong with an unsigned TLSA?
  4. Under DANE, why can a self-signed certificate be trusted? Who takes over the CA's role?
  5. Why is the impostor's certificate rejected by this web server even if it were signed by a different, valid CA?
  6. Why is the AD flag not set in this lab? How could you get it set (see Lab 13)?

References

Verified Run Log (2026-07-07)

This lab has been confirmed reproducible on real hardware.

Environment:

  • Ubuntu 26.04 LTS (kernel 7.0.0-27-generic, x86_64)
  • Docker 29.1.3
  • containerlab 0.77.0
  • auth: protocol-lab/bind9:9.20, a thin wrapper around internetsystemsconsortium/bind9:9.20 (BIND 9.20.24)
  • client / web: nicolaka/netshoot:latest (dig 9.20.23, openssl 3.x)

Running PATH="/tmp/pl-shim:$PATH" ./scripts/labctl.sh run dane-17 performed deploy → verify → destroy, and verification.json returned "status": "verified". The web certificate was generated at run time; the TLSA (3 1 1) was computed from its public key and placed into the DNSSEC-signed zone.

Fetching the TLSA record (DNSSEC-signed)

$ docker exec clab-dane-17-client dig +dnssec @10.0.1.2 _443._tcp.www.example.lab TLSA

_443._tcp.www.example.lab. 300 IN TLSA  3 1 1 5E26EC745C33B4CACA06BC6BA2AE55E0D5BA0FDDB729616613AC1AAC3D3D82D6
_443._tcp.www.example.lab. 300 IN RRSIG TLSA 13 5 300 20360704090109 20260707090109 12614 example.lab. ...

TLSA 3 1 1 ... sits right next to RRSIG TLSA. The record is DNSSEC-signed, so this pin can be trusted.

DANE-verifying the genuine certificate (self-signed, yet Verify 0)

$ echo Q | openssl s_client -connect 10.0.2.2:443 \
    -dane_tlsa_domain www.example.lab -dane_tlsa_rrdata '3 1 1 5E26...3D3D82D6'

DANE TLSA 3 1 1 ...b729616613ac1aac3d3d82d6 matched the EE certificate at depth 0
Verify return code: 0 (ok)

Even though no CA signed this certificate (it is self-signed), the result is Verify return code: 0 — because the DNSSEC-protected TLSA vouches for it.

Checking the impostor against the same TLSA (rejected)

$ echo Q | openssl s_client -connect 10.0.2.2:8443 \
    -dane_tlsa_domain www.example.lab -dane_tlsa_rrdata '3 1 1 5E26...3D3D82D6'

verify error:num=65:no matching DANE TLSA records
Verify return code: 65 (no matching DANE TLSA records)

The certificate with a different key doesn't match the published TLSA and is rejected. DANE detects certificate swapping with a fingerprint anchored in DNS — without a CA (or alongside one).

Cleanup

containerlab destroy -t dane-17.clab.yml --cleanup

That's DANE: a certificate fingerprint published in DNS, guarded by DNSSEC, doing the job a CA normally does — pinning the real cert and rejecting the impostor.

Explore the full Protocol Lab series here: github.com/pathvector-studio/protocol-lab. If these labs are useful to you, please ⭐ star the repo on GitHub — it genuinely helps others find the project.

Next up, we'll keep pulling on the thread of DNS-anchored trust and what else the resolver can vouch for.

Read more