Skip to content

From plaintext DNS to DNS over TLS: watching a query disappear inside TLS

One DNS query, captured plain and then over DNS over TLS, read back in termshark: github.com legible in the hex pane, then ciphertext. Plus what a router does when asked for DoT, and what an observer can still read either way.

11 min read

Type github.com into a browser and the page loads in well under a second. Before any of it could load, something on this machine had to turn that name into an address, and that lookup went somewhere.

This post follows one question the whole way down: who saw it. Every claim below is checked against a packet capture taken on the machine being described, read back in termshark, and every command is one you can run yourself.

What happens when you type a domain into a browser

A browser cannot open a connection to a name. Connections are addressed by IP address, so before it sends a byte to github.com the browser has to turn that name into one. That lookup is a DNS query, and on this machine it takes the same short path every time.

DNS queryApplicationthe browser, or anything else askingover the networkStub resolveron this machine, at 127.0.0.53resolvedRecursive resolversomewhere on the networkAnsweran address, handed back
Four hops. Everything interesting in this post happens on the middle one.

Two separate questions decide what that middle hop looks like: which resolver answers, and what protects the query on its way there. Confusing the two is the most common mistake about encrypted DNS, and the rest of this post is mostly about keeping them apart.

Plain DNS: one query to the router

dig sends a single DNS query and prints the whole answer. Given a server with @, it asks that server directly, which makes it the right tool for asking a specific resolver a specific question.

A home router commonly answers DNS itself, from cache or by forwarding to whatever servers an internet service provider (ISP) handed it over Dynamic Host Configuration Protocol (DHCP). Asking it directly shows what a device on this network gets by default.

bash
dig @192.168.100.1 github.com
output
dig querying the router at 192.168.100.1 for github.com: an answer of 20.207.73.82 with a time to live of 46, and a SERVER line reading 192.168.100.1#53(192.168.100.1) (UDP)
The router answers. The SERVER line names the transport it answered over: (UDP), port 53

Two fields carry the whole story. SERVER names who answered and how: 192.168.100.1#53(192.168.100.1) (UDP), port 53, no encryption of any kind. ANSWER SECTION carries the address, 20.207.73.82, with a time to live (TTL) of 46, which is how many seconds a resolver may reuse that answer before asking again.

MSG SIZE rcvd: 55 is worth remembering. Fifty-five bytes, sent in the clear.

Three jobs called "resolver"

The word "resolver" is used for three different machines doing three different jobs, and the rest of this post depends on telling them apart.

A stub resolver takes requests from applications on one machine and forwards them onward. It resolves nothing itself. On this machine that job belongs to systemd-resolved, listening on 127.0.0.53.

A recursive resolver does the real work: it answers from cache, or walks the DNS hierarchy until it has the answer. Cloudflare's 1.1.1.1 and 1.0.0.1 are recursive resolvers.

An authoritative server holds the actual records for one zone, and is where a recursive resolver's chain of questions ends.

RoleWhat it doesOn this machine
Stub resolverForwards a request; resolves nothing itselfsystemd-resolved at 127.0.0.53
Recursive resolverResolves a name, from cache or by walking the hierarchyCloudflare at 1.1.1.1 and 1.0.0.1
Authoritative serverHolds the records for one zonegithub.com's own nameservers

Changing the resolver: the same query, sent to Cloudflare

The usual advice is to stop using the network's own DNS and point at a public resolver instead. So do exactly that, and watch what changes.

bash
dig @1.1.1.1 github.com
output
The same dig query sent to Cloudflare at 1.1.1.1: an answer of 140.82.121.4 and a SERVER line reading 1.1.1.1#53(1.1.1.1) (UDP), still on port 53
A different resolver, a different answer, and the same transport: (UDP), port 53, MSG SIZE 55

Two things changed and one did not.

The resolver changed, and so did the answer: 140.82.121.4 from Cloudflare against 20.207.73.82 from the router. Both are real addresses for github.com; two resolvers simply had different entries cached at the moment they were asked.

The transport did not change at all. 1.1.1.1#53(1.1.1.1) (UDP), port 53, 55 bytes, exactly as readable on the wire as the first query was.

Key distinction

Changing the DNS resolver changes who receives the query. It does nothing to how that query travels. Those are two separate settings, and only one of them was just changed.

DNS over TLS: the same query inside an encrypted connection

DNS over TLS (DoT) carries an ordinary DNS query inside a Transport Layer Security (TLS) connection instead of a bare User Datagram Protocol (UDP) packet. The query and the answer are still DNS messages, unchanged. What changes is what carries them: TCP on port 853, with TLS in between.

Recent versions of dig speak it directly.

bash
dig +tls -p 853 @1.1.1.1 github.com
output
dig querying Cloudflare over DNS over TLS: the answer 140.82.121.3, a PADDING line of 409 bytes in the OPT pseudosection, a SERVER line reading 1.1.1.1#853(1.1.1.1) (TLS), and MSG SIZE rcvd 468
Same resolver, same question. The SERVER line now reads (TLS) on port 853, and the response is 468 bytes rather than 55

SERVER now reads 1.1.1.1#853(1.1.1.1) (TLS). Same resolver, same question, same kind of answer.

The size is the interesting part. MSG SIZE rcvd: 468, against 55 for the identical query in plain text, and the reason is one line above it in the output: PADDING: (409 bytes). An encrypted query still has a length, and a length is something an observer can measure even when the bytes are unreadable, so DoT responses are commonly padded to blunt exactly that. That padding is a hint about what encryption does and does not cover here, and the rest of this post keeps pulling at it.

The packet stack under each transport

Nothing about DNS itself changed between those two queries. What changed is the stack of protocols underneath the DNS message:

Choose a protocol stack to inspect
Ethernet
  IP
    UDP
      DNS
        Query: github.com  (readable)
Ethernet
  IP
    TCP
      TLS
        DNS
          Query: github.com  (encrypted)

Where the encryption starts, and where it stops

TLS protects the DNS message between two specific endpoints: the client that opened the connection, and the DoT server that answers it. Nothing before the first, nothing after the second.

DNS request, on this machineBrowser / applicationasks for github.comTLS, TCP 853systemd-resolvedstub resolver, and the DoT clientresolvedCloudflare (1.1.1.1)TLS terminates, DNS message read140.82.121.3handed back to the browser
systemd-resolved opens the encrypted connection itself. The network between it and Cloudflare carries that connection without joining it.

Three roles, and it is worth being exact about each:

systemd-resolved is the stub resolver and the DoT client at once. It takes the request from the application and, because DNS over TLS is switched on, opens the encrypted connection itself. The application never negotiated any of it.

Cloudflare terminates that connection. It decrypts the DNS message and resolves the name, because no resolver, encrypted or otherwise, can answer a question it cannot read.

The ISP and every other network in between carry the packets. They are not participants in the TLS session, and do not hold the keys to it. They also do not encrypt anything on your behalf: the encryption was already there when the packets reached them.

The configuration that produces this

systemd-resolved's own view of its upstream state:

bash
resolvectl status
output
resolvectl status showing Global with +DNSOverTLS, DNS Servers 1.1.1.1 and 1.0.0.1, fallback servers 8.8.8.8 and 8.8.4.4, resolv.conf mode stub, and Link 2 wlp0s20f3 with its own DNS server 192.168.100.1
+DNSOverTLS globally, and Cloudflare as the upstream. This is the machine the dig captures above were run on

That behaviour comes from one file:

bash
grep -vE '^#|^$' /etc/systemd/resolved.conf | bat -l ini --style=plain --color=always
output
The active lines of /etc/systemd/resolved.conf: DNS=1.1.1.1 1.0.0.1, FallbackDNS=8.8.8.8 8.8.4.4, DNSOverTLS=yes
Three active lines. Every other line in the file is a commented-out default

DNS= names the upstream resolvers, which is the who. DNSOverTLS=yes is the how, and it is a separate line for the same reason the two are separate questions: remove it and the same servers are queried over plain UDP.

Applications never talk to any of this directly. They find the stub through one file:

bash
bat -l ini --style=plain --color=always /etc/resolv.conf
output
/etc/resolv.conf: a comment block explaining it is managed by systemd-resolved, then nameserver 127.0.0.53 and options edns0 trust-ad
127.0.0.53 is a loopback address. A query sent there has not left the machine yet

nameserver 127.0.0.53 is loopback, reachable from this machine and nowhere else. An application sends its query there, systemd-resolved answers on that address, and only then decides where the query goes upstream and how it is wrapped.

That indirection is the whole point: every application on the machine points at one fixed local address, and the real resolver, encrypted or not, can be changed underneath them without a single application knowing.

Misconception

"127.0.0.53 is Cloudflare." No. It is a loopback address on this machine where systemd-resolved listens. Cloudflare is upstream of it, and the application never addresses Cloudflare at all.

What a resolver that has never heard of DoT does with the connection

DNSOverTLS=yes says how to reach a resolver. It says nothing about whether the resolver on the other end can be reached that way.

A DNS server that has answered on UDP port 53 for a decade and knows nothing about DNS over TLS is extremely common: a home router handing itself out over Dynamic Host Configuration Protocol (DHCP) is usually exactly that. Here is one, answering plain DNS perfectly well and then being asked for the same name over TLS.

bash
dig +noall +answer @192.0.2.60 example.com          # port 53, answered
dig +tls -p 853 @192.0.2.60 example.com             # port 853, refused
termshark -r dot-refused-853.pcapng
output
termshark showing two TCP connection attempts to 192.0.2.60 on port 853, each answered immediately with a RST, ACK, with the TCP layer of one reply expanded to show source port 853
Every attempt on port 853 is answered with a reset, in a fraction of a millisecond

Every attempt is a TCP SYN to port 853, and every reply is a RST, ACK: a connection reset (RST), sent immediately, because nothing is listening there. The same server answered a plain query on port 53 a moment earlier. It is not broken and it is not refusing you; it simply has no DNS over TLS to offer.

A resolver configured with DNSOverTLS=yes meets that with a decision to make, and the decision is the interesting part.

bash
resolvectl query wikipedia.org
output
resolvectl query wikipedia.org returning two addresses over link wlp0s20f3, with a line reading: Data is authenticated: no; Data was acquired via local or encrypted transport: yes
The query succeeded, and systemd-resolved states the path it used: acquired via local or encrypted transport, yes

That line is systemd-resolved reporting which path an answer came back on, and it is the one to check when you want to know whether a setting is doing anything. Under yes, a server that cannot do DNS over TLS is not used at all: the resolver moves to one that can, and a name that no configured server can answer encrypted simply fails to resolve. Under opportunistic, the same refusal is met by falling back to plain text, quietly, with no error anywhere and no line in any output to tell you it happened.

That is the whole difference between the two settings, and it only shows up at the moment something refuses.

The capture below is from a lab rather than from this machine's own network, built on ranges reserved for documentation: 192.0.2.0/24 from RFC 5737, and hardware addresses from the RFC 7042 block. The exchange in it is a real one, run against a real resolver; what it is not is a recording of somebody's house, which is why it can be shown whole.

The query name, in plain sight

dig reports what it did. A packet capture is the independent check on that report. termshark is Wireshark's dissection engine driven from a terminal, reading the same capture file Wireshark would.

Here is a plain DNS query over UDP, with its DNS layer expanded:

bash
dig @192.0.2.53 example.com
termshark -r dns-only.pcapng
output
termshark showing two DNS packets between 192.0.2.10 and 192.0.2.53: a standard query for example.com and its response. The detail pane has User Datagram Protocol with destination port 53 and the Domain Name System layer expanded, and the hex pane shows the ASCII text example.com in the packet bytes
Destination port 53, the DNS layer fully dissected, and example.com legible in the hex pane's own ASCII column

Read the panes from the bottom up. The hex dump on the right holds e xample.c m in readable text, because that is what is in the packet. The detail pane above it walks the whole stack, Ethernet, Internet Protocol Version 4, User Datagram Protocol, Dst Port: 53, Domain Name System (query), and every one of those layers dissects cleanly because none of them is encrypted.

Nothing was broken to read this. The capture is a passive recording of bytes that crossed a link in the clear.

The capture below is from a lab rather than from this machine's own network, built on ranges reserved for documentation: 192.0.2.0/24 from RFC 5737, and hardware addresses from the RFC 7042 block. The exchange in it is a real one, run against a real resolver; what it is not is a recording of somebody's house, which is why it can be shown whole.

The same query, inside TLS

Now the same question asked over DoT, from the same lab, with the same tool, expanded to the same depth:

bash
dig +tls -p 853 @192.0.2.53 example.com
termshark -r dot-only.pcapng
output
termshark showing a TLS session on port 853: a Client Hello, a Server Hello, then Application Data frames. The detail pane shows Transmission Control Protocol with destination port 853 and an expanded Transport Layer Security layer reading TLSv1.3 Record Layer: Application Data Protocol: Domain Name System, and the hex pane is unreadable ciphertext
Same tool, same depth. Destination port 853, TLS 1.3 Application Data, and a hex pane with nothing legible in it

Everything structural is still visible. The TCP handshake, the Client Hello, the Server Hello, the record layer, the size of every frame and the moment it was sent. termshark even labels the record Application Data Protocol: Domain Name System, which is not decryption: port 853 is registered to DNS over TLS, so the dissector knows what protocol should be inside without being able to see any of it.

The hex pane is the difference. In the previous capture it held a hostname. Here it holds ciphertext, and no amount of dissection turns one into the other.

Misconception

"A capture tool cannot see DNS over TLS traffic." It sees the whole connection, as above: addresses, ports, handshake, timing, sizes. What it cannot do is decode a DNS message that was never sent in the clear.

What a passive observer can still read

Both captures were taken from the same position on the network, on the same interface, minutes apart. Switch between them and the list of what an observer can read barely moves:

Choose what the observer is watching
  • Source addressreadablereadable192.168.100.40, the machine that asked
  • Destination addressreadablereadable1.1.1.1, the resolver being asked
  • Transport and portreadablereadableUDP 53 in the first case, TCP 853 in the second
  • Timing and packet sizesreadablereadablewhen the query was sent, and how large it was
  • That this is DNS at allreadablereadableport 853 is registered to DNS over TLS and nothing else
  • The name being looked upreadableencryptedgithub.com, in the clear, in the packet's own bytes
  • The answer that came backreadableencrypted140.82.121.3, in the clear, in the response

Two rows change. Every other row is identical, and the destination address is the row that does the most work: an observer who knows 1.1.1.1 belongs to Cloudflare learns that this machine uses encrypted DNS, and which company answers its queries, without reading a single name.

DoT was never designed to hide that.

DNS over HTTPS: the same query inside an ordinary web request

DNS over HTTPS (DoH) is the other way to encrypt the same message. It carries the DNS query as the body of an HTTPS request rather than as its own protocol on its own port.

bash
dig +https @1.1.1.1 github.com
output
dig querying Cloudflare over DNS over HTTPS: the answer 140.82.121.3 and a SERVER line reading 1.1.1.1#443(1.1.1.1) (HTTPS)
The same answer again, over port 443 this time: the port every other HTTPS connection on the machine already uses

SERVER reads 1.1.1.1#443(1.1.1.1) (HTTPS). That port number is the entire practical difference from DoT.

A DoT connection is identifiable as DNS by its port even when its contents are sealed, which is what the capture above showed. A DoH connection to port 443 sits in the same column as every other HTTPS request the machine makes. Both encrypt the DNS message identically well; they differ in how loudly they announce that DNS is what they are carrying.

PropertyPlain DNSDNS over TLSDNS over HTTPS
EncryptionNoneTLSTLS
Typical transportUDPTCPHTTPS, commonly HTTP/3 over Quick UDP Internet Connections (QUIC)
Typical port53853443
Query readable by a passive observerYesNoNo
Identifiable as DNS on the wireYesYes, by its portNot usually
Observed hereMSG SIZE 55MSG SIZE 468, paddedMSG SIZE 55

What DNS over TLS leaves exposed

Encryption in transit answers one question and leaves two others open.

The first is metadata, covered above: addresses, ports, timing and sizes all survive. The padding in the DoT response is a partial answer to the size half of that, and only a partial one.

The second is the resolver itself. Cloudflare decrypts every query this machine sends, because it has to in order to answer. Moving from a router that answers in plain text to a public resolver that answers over TLS does not remove a party who sees the queries; it changes which party that is, and moves the traffic out of reach of everyone in between. Whether that is an improvement depends entirely on who you would rather have the list.

The resolvectl status capture also read DNSSEC=no/unsupported. Domain Name System Security Extensions (DNSSEC) authenticate that an answer was not altered on its way to you, which is a different property from confidentiality, provided by a different mechanism. DoT hides the query. It does not vouch for the answer.

Misconception

"I changed my DNS to 1.1.1.1, so my DNS is encrypted." No. Two captures in this post used 1.1.1.1, and one of them was plain UDP on port 53, fully readable.

Misconception

"My ISP cannot see anything when I use DoT." The DNS message is sealed. The destination address, the port, the TLS handshake, the timing and the packet sizes are all still on the wire.

Misconception

"Cloudflare keeps my query encrypted through the rest of DNS." The TLS session runs between this machine and Cloudflare, and terminates there. What Cloudflare does afterwards is ordinary recursive resolution.

The core mental model: which resolver, and how the query travels

Two questions, two settings, and neither one answers the other.

DNS= decides who receives the query. DNSOverTLS= decides what protects it on the way there. A resolver can be trustworthy and still be sent questions in plain text; a query can be sealed end to end and still be read in full the moment it arrives at the resolver that has to answer it.

Everything in this post is reproducible in about ten minutes: set the two lines, run dig twice, capture with dumpcap, and read the result back in termshark. The hex pane will tell you which of the two questions you actually answered.