DNS over TLS and DNS over HTTPS protect DNS traffic on the encrypted connection between their endpoints. For a typical client, that connection ends at the selected resolver. The resolver can read the question and answer, and its later queries may follow different transports. Encrypting this hop does not automatically encrypt every recursive exchange or make the resolver unable to log the names it handles.
When it breaks
An encrypted socket without the intended certificate validation can connect to the wrong endpoint. Verify both the certificate’s trust chain and the resolver identity expected by the client. A successful handshake alone does not prove those checks were enabled. Configuration that silently uses a different resolver or a plaintext path on failure also changes the privacy contract and must be understood through the actual caller.
DoH uses HTTPS, but port 443 does not make its traffic unidentifiable, unblockable, or indistinguishable in every network. Destination addresses, connection timing, and other metadata remain observable. TLS termination points can inspect plaintext HTTP. Choose a resolver and transport policy with those boundaries in mind rather than assuming that an HTTPS URL removes every source of DNS-related information exposure.
The two protocols also have different message framing. DoT normally uses port 853 and carries DNS messages with the TCP two-byte length prefix inside TLS. DoH normally uses an HTTPS endpoint on port 443. Standard DoH carries DNS wire-format messages using application/dns-message; POST places the message in the body, while GET uses a base64url-encoded dns parameter. It is not defined as arbitrary JSON over HTTPS.
Check it quickly
Save this as check-encrypted-dns.py and run python3 check-encrypted-dns.py with Docker. The pinned BIND 9.18.49 environment supports DoT and DoH through its linked TLS and HTTP/2 libraries. The script creates a disposable certificate, trusts it explicitly for successful probes, and uses isolated loopback ports 8531 and 8443. It does not install a new host-wide trust anchor:
from pathlib import Path
from tempfile import TemporaryDirectory
import json,subprocess,uuid
recipe='FROM python@sha256:528257d48c1da0dcecc2e725d1ae34498d60c965f1241e39cd6a85a8859bdf84\nRUN apt-get update && apt-get install -y --no-install-recommends bind9=1:9.18.49-1~deb12u2 bind9-dnsutils=1:9.18.49-1~deb12u2 dnsmasq-base=2.90-4~deb12u2 python3-dnspython=2.3.0-1 && rm -rf /var/lib/apt/lists/*\nCMD ["/usr/bin/python3"]\n'
program=r"""
from contextlib import ExitStack,contextmanager
from pathlib import Path
from tempfile import TemporaryDirectory
import json,re,signal,subprocess,time
import dns.exception,dns.message,dns.query,dns.rcode,dns.rdatatype
def run(*args,check=True):
result=subprocess.run(args,capture_output=True,text=True,timeout=15)
if check:assert result.returncode==0,(args,result.returncode,result.stdout,result.stderr)
return result
def query(name,kind='A',port=1053):
return dns.query.udp(dns.message.make_query(name,kind),'127.0.0.1',port=port,timeout=1)
def values(response,kind='A'):
wanted=dns.rdatatype.from_text(kind)
return [item.to_text() for rrset in response.answer if rrset.rdtype==wanted for item in rrset]
def show(response):
def section(records):return [{'name':str(rr.name),'type':dns.rdatatype.to_text(rr.rdtype),'ttl':rr.ttl,'data':[item.to_text() for item in rr]} for rr in records]
return {'rcode':dns.rcode.to_text(response.rcode()),'answer':section(response.answer),'authority':section(response.authority)}
def wait(predicate,timeout=15):
deadline=time.monotonic()+timeout
while time.monotonic()<deadline:
try:
if predicate():return
except (OSError,dns.exception.Timeout):pass
time.sleep(.05)
raise AssertionError('DNS fixture condition did not become true')
def stop(process):
if process.poll() is None:
process.terminate()
try:process.wait(timeout=5)
except subprocess.TimeoutExpired:process.kill();process.wait(timeout=5)
@contextmanager
def lab(records,soa_ttl=8,minimum=4,extra=''):
with TemporaryDirectory(prefix='xbcatm-dns-') as directory,ExitStack() as stack:
root=Path(directory);zone=root/'zone';conf=root/'named.conf';log=root/'named.log';serial=1
def save(body):
zone.write_text(f'$ORIGIN lab.test.\n$TTL 30\n@ {soa_ttl} IN SOA ns.lab.test. hostmaster.lab.test. ({serial} 60 60 3600 {minimum})\n@ IN NS ns.lab.test.\nns IN A 127.0.0.1\n'+body+'\n')
run('named-checkzone','lab.test',str(zone))
save(records)
conf.write_text(f'''options {{ directory "{root}"; listen-on port 1053 {{127.0.0.1;}}; listen-on-v6 {{none;}};
recursion no; dnssec-validation no; notify no; querylog yes; max-cache-size 8m; pid-file "{root}/named.pid"; session-keyfile none;
{extra} }};
controls {{}};
zone "lab.test" {{ type primary; file "{zone}"; }};
''')
run('named-checkconf',str(conf))
output=stack.enter_context(log.open('w'))
authority=subprocess.Popen(['named','-g','-c',str(conf),'-n','1','-U','1'],stdout=output,stderr=subprocess.STDOUT)
stack.callback(stop,authority)
def ready():
assert authority.poll() is None,log.read_text()
response=query('lab.test.','SOA');return response.rcode()==0 and response.answer[0][0].serial==serial
wait(ready)
def update(body):
nonlocal serial
serial+=1;save(body);authority.send_signal(signal.SIGHUP);wait(ready)
def cache(port=1054,upstream=1053,size=150,options=()):
logfile=root/('cache-'+str(port)+'.log');stream=stack.enter_context(logfile.open('w'))
command=['dnsmasq','--no-daemon','--conf-file=/dev/null','--no-resolv','--no-hosts','--bind-interfaces',
'--listen-address=127.0.0.1','--port='+str(port),'--server=127.0.0.1#'+str(upstream),
'--cache-size='+str(size),'--log-queries','--log-facility=-',*options]
process=subprocess.Popen(command,stdout=stream,stderr=subprocess.STDOUT);stack.callback(stop,process)
def cache_ready():
assert process.poll() is None,logfile.read_text()
return query('lab.test.','SOA',port).rcode()==0
wait(cache_ready);return process,logfile
def queries(name,kind='A'):
return sum(bool(re.search(r'query: '+re.escape(name.rstrip('.'))+r' IN '+kind+r'\b',line)) for line in log.read_text().splitlines())
yield root,update,cache,queries
@contextmanager
def named_process(root,config):
root.mkdir(exist_ok=True);conf=root/'named.conf';log=root/'named.log';conf.write_text(config)
run('named-checkconf',str(conf))
with log.open('w') as output:
process=subprocess.Popen(['named','-g','-c',str(conf),'-n','1','-U','1'],stdout=output,stderr=subprocess.STDOUT)
try:
def ready():
assert process.poll() is None,log.read_text()
return re.search(r'\brunning\s*$',log.read_text(),re.M) is not None
wait(ready);yield process,log
finally:stop(process)
import socket,ssl,struct
def exact(stream,count):
data=b''
while len(data)<count:
more=stream.recv(count-len(data));assert more,'unexpected EOF';data+=more
return data
with TemporaryDirectory(prefix='xbcatm-dot-') as directory:
root=Path(directory);certificate=root/'certificate.pem';key=root/'key.pem'
run('openssl','req','-x509','-newkey','rsa:2048','-nodes','-days','2','-subj','/CN=resolver.lab.test',
'-addext','subjectAltName=DNS:resolver.lab.test,IP:127.0.0.1','-keyout',str(key),'-out',str(certificate))
zone=root/'zone';zone.write_text('$ORIGIN lab.test.\n$TTL 30\n@ IN SOA ns.lab.test. hostmaster.lab.test. (1 60 60 3600 30)\n@ IN NS ns.lab.test.\nns IN A 127.0.0.1\nservice IN A 127.0.0.2\n')
config=f'''tls local-tls {{ key-file "{key}"; cert-file "{certificate}"; }};
http local-http {{ endpoints {{ "/dns-query"; }}; }};
options {{ directory "{root}"; listen-on port 1053 {{127.0.0.1;}};
listen-on port 8531 tls local-tls {{127.0.0.1;}};
listen-on port 8443 tls local-tls http local-http {{127.0.0.1;}};
listen-on-v6 {{none;}}; recursion no; dnssec-validation no; notify no;
pid-file "{root}/named.pid"; session-keyfile none; max-cache-size 8m; }};
controls {{}}; zone "lab.test" {{ type primary; file "{zone}"; }};
'''
with named_process(root,config):
context=ssl.create_default_context(cafile=str(certificate));context.set_alpn_protocols(['dot'])
with socket.create_connection(('127.0.0.1',8531),timeout=2) as raw,context.wrap_socket(raw,server_hostname='resolver.lab.test') as stream:
q=dns.message.make_query('service.lab.test.','A');wire=q.to_wire();stream.sendall(struct.pack('!H',len(wire))+wire)
count=struct.unpack('!H',exact(stream,2))[0];response=dns.message.from_wire(exact(stream,count))
assert response.id==q.id and values(response)==['127.0.0.2'];tls_version=stream.version();alpn=stream.selected_alpn_protocol()
failures={}
for name,ctx,hostname in [('untrusted',ssl.create_default_context(),'resolver.lab.test'),('wrong_name',context,'different.lab.test')]:
try:
with socket.create_connection(('127.0.0.1',8531),timeout=2) as raw,ctx.wrap_socket(raw,server_hostname=hostname):pass
raise AssertionError('certificate validation unexpectedly succeeded')
except ssl.SSLCertVerificationError as error:failures[name]=error.verify_code
https={}
for mode in ('https-get','https-post'):
output=run('dig','@127.0.0.1','-p','8443','service.lab.test.','A','+'+mode,'+tls-ca='+str(certificate),
'+tls-hostname=resolver.lab.test','+short','+tries=1','+time=2').stdout
assert output.strip()=='127.0.0.2',output;https[mode]=output.strip()
doh_rejections={}
for label,verification in [('untrusted',['+tls-ca','+tls-hostname=resolver.lab.test']),
('wrong_name',['+tls-ca='+str(certificate),'+tls-hostname=different.lab.test'])]:
rejected=run('dig','@127.0.0.1','-p','8443','service.lab.test.','A','+https-get',*verification,'+short','+tries=1','+time=2',check=False)
diagnostic=rejected.stdout+rejected.stderr
assert 'TLS peer certificate verification' in diagnostic and 'failed:' in diagnostic,(label,diagnostic)
assert not re.search(r'^127\.0\.0\.2$',diagnostic,re.M)
doh_rejections[label]={'exit':rejected.returncode,'diagnostic':diagnostic.strip(),'no_DNS_answer':True}
wrong_path=run('dig','@127.0.0.1','-p','8443','service.lab.test.','A','+https=/wrong',
'+tls-ca='+str(certificate),'+tls-hostname=resolver.lab.test','+tries=1','+time=2',check=False)
assert wrong_path.returncode!=0
print(json.dumps({'DoT':{'answer':values(response),'TLS_version':tls_version,'ALPN':alpn},'certificate_rejections':failures,
'DoH_verified_TLS':https,'DoH_certificate_rejections':doh_rejections,'wrong_DoH_path_exit':wrong_path.returncode,
'wrong_DoH_path_diagnostic':wrong_path.stdout+wrong_path.stderr},sort_keys=True))
"""
name='xbcatm-dns-'+uuid.uuid4().hex[:10]
def docker(*args,**kwargs):return subprocess.run(['docker',*args],capture_output=True,text=True,timeout=300,**kwargs)
try:
with TemporaryDirectory(prefix=name+'-') as directory:
root=Path(directory);(root/'Dockerfile').write_text(recipe)
built=docker('build','-t',name,str(root));assert built.returncode==0,built.stderr
result=docker('run','--rm','-i','--name',name,'--network','none','--memory','384m','--pids-limit','128',name,
'/usr/bin/python3','-',input=program)
assert result.returncode==0,result.stderr
print(json.dumps(json.loads(result.stdout),sort_keys=True))
finally:
docker('rm','-f',name);docker('image','rm',name)
The Python DoT client authenticates resolver.lab.test, negotiates TLS 1.3 with ALPN dot in the recorded run, and sends a length-prefixed DNS question. It reads the complete framed response, verifies the message ID, and receives A 127.0.0.2 for service.lab.test. Supplying the loopback connection address separately from the expected TLS hostname keeps endpoint location and authenticated identity explicit.
Two DoT controls fail certificate verification: using the system trust store without the disposable certificate, and requesting a different hostname while trusting that certificate. These failures demonstrate both trust and name checks. The test catches the specific certificate-verification exception; it does not classify an arbitrary socket failure as successful proof that certificate validation worked.
For DoH, dig performs HTTPS GET and POST queries against /dns-query with explicit CA and hostname validation. Both return 127.0.0.2. The untrusted-certificate and wrong-hostname controls emit certificate-verification errors without a DNS answer, despite exiting zero in this version. The assertions inspect those diagnostics instead of trusting exit status. Querying /wrong with valid trust settings also fails: authenticated HTTPS still needs the correct endpoint path.
The bad-path diagnostic is a failed exchange, not NXDOMAIN and not evidence that service.lab.test disappeared. This example does not extract a specific HTTP status from that diagnostic. It preserves the distinction between transport or endpoint failure and a completed DNS response carrying a DNS response code, preventing a generic command failure from being reported as an authoritative negative answer.
Keep the resolver and bootstrap configuration visible
A client needs an address for its encrypted resolver before it can connect. Deployments can provide bootstrap addresses or another documented discovery path. The fixture supplies loopback directly, avoiding a circular dependency on the same resolver. In production, verify how bootstrap, certificate renewal, endpoint changes, and connection failures behave for the client implementation actually in use.
Check whether application queries traverse the configured encrypted connection. Other processes may use the operating system’s resolver, another browser setting, or cached data. One successful DoH command demonstrates that command’s path, not a machine-wide policy. Observe the intended applications and their failure behavior before claiming that all their name lookups receive the same transport protection.
Finally, DNSSEC and encrypted DNS address different properties. DNSSEC can authenticate signed DNS data through a trust chain; TLS protects and authenticates the selected transport endpoint. Neither mechanism automatically supplies the other’s guarantee. Use both where the service contract requires them, and keep evidence for certificate validation, actual encrypted queries, resolver trust, and DNS-data validation distinct.
Sources
- RFC 7858 DNS over TLS, TLS transport, dedicated port, and connection framing, checked 2026-09-12.
- RFC 8484 DNS over HTTPS, DoH GET and POST mapping and application/dns-message, checked 2026-09-12.
- BIND 9.18.49 tools, dig encrypted transports and explicit certificate validation, checked 2026-09-12.