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