Skip to main content

DNS-over-HTTPS (DoH) Complete Guide: Resolution Flow, Server Setup, and Python Client

Created: March 4, 2026 Larry Qu 4 min read

Introduction

DNS-over-HTTPS (DoH, RFC 8484) encrypts DNS queries within HTTPS traffic on port 443. Unlike traditional DNS (plaintext UDP port 53) and DNS-over-TLS (dedicated port 853), DoH hides DNS queries inside normal HTTPS traffic, making them indistinguishable from web browsing. This prevents ISPs from logging DNS queries and blocks DNS manipulation by network intermediaries.

This guide covers a Mermaid comparison of plain DNS vs DoH resolution flows, Nginx DoH server configuration with TLS 1.3, a Python client that sends RFC 8484 DNS queries to any DoH provider, systemd-resolved and Firefox DoH setup, and enterprise deployment architecture with internal DoH servers.

How DoH Works: Resolution Flow

sequenceDiagram
    participant B as Browser/App
    participant DoH as DoH Server (1.1.1.1)
    participant Resolver as Upstream DNS (Root/TLD)

    Note over B,Resolver: Traditional DNS (plaintext)
    B->>Resolver: UDP query for example.com
    Resolver-->>B: UDP response with IP
    Note over B,Resolver: Visible to ISP, anyone on network

    Note over B,DoH: DNS-over-HTTPS (encrypted)
    B->>DoH: HTTPS POST /dns-query
    Note over B,DoH: TLS 1.3 encrypted
    DoH->>Resolver: Upstream resolution (encrypted)
    Resolver-->>DoH: IP response
    DoH-->>B: HTTPS 200 + DNS response
    Note over B,DoH: ISP sees: HTTPS to 1.1.1.1:443
    Note over B,DoH: ISP cannot see: which domain was queried

Protocol Comparison

Aspect Traditional DNS DoT (RFC 7858) DoH (RFC 8484)
Port UDP 53 TCP 853 TCP 443
Encryption None TLS 1.3 TLS 1.3
Visibility Plaintext to ISP Encrypted, but port 853 is identifiable Encrypted, looks like HTTPS
Blocking risk Easy to block Easy to block (port 853) Hard to block (port 443)
0-RTT No No TLS 1.3 0-RTT
Standard RFC 1035 RFC 7858 RFC 8484

Nginx DoH Server

Configure Nginx as a DoH endpoint that proxies DNS-over-HTTPS requests to a local DNS resolver:

# /etc/nginx/sites-available/doh
server {
    listen 443 ssl http2;
    server_name dns.example.com;

    # TLS 1.3 only for minimum latency
    ssl_protocols TLSv1.3;
    ssl_ciphers TLS_AES_256_GCM_SHA384:TLS_CHACHA20_POLY1305_SHA256;
    ssl_certificate /etc/ssl/certs/dns.example.com.crt;
    ssl_certificate_key /etc/ssl/private/dns.example.com.key;

    location = /dns-query {
        # Only POST method per RFC 8484
        limit_except POST { deny all; }

        # Proxy to local DNS resolver (Unbound, CoreDNS)
        proxy_pass http://127.0.0.1:53;
        proxy_set_header X-Forwarded-For $remote_addr;

        # DoH requires specific content type
        proxy_set_header Content-Type application/dns-message;

        # Large enough for DNS responses (max ~4K)
        proxy_buffer_size 4k;
        proxy_buffers 8 4k;
    }

    # Health check endpoint
    location = /health { return 200 "OK"; }
}

Python DoH Client

Send DNS queries to any DoH endpoint using RFC 8484 wire format over HTTPS:

import dns.message
import dns.rdatatype
import httpx

class DoHClient:
    """DNS-over-HTTPS client using RFC 8484 wire format.

    Encodes DNS queries as binary DNS wire format, sends them
    via HTTPS POST with content-type application/dns-message,
    and decodes the response.
    """

    PROVIDERS = {
        "cloudflare": "https://1.1.1.1/dns-query",
        "google": "https://dns.google/dns-query",
        "quad9": "https://dns.quad9.net/dns-query",
    }

    def __init__(self, provider: str = "cloudflare"):
        self.url = self.PROVIDERS.get(provider, provider)
        self.client = httpx.Client(http2=True)  # HTTP/2 for efficiency

    def resolve(self, domain: str, record_type: str = "A") -> list:
        """Resolve a DNS record via DoH.

        Args:
            domain: Domain to query (e.g., 'example.com')
            record_type: DNS record type ('A', 'AAAA', 'MX', etc.)

        Returns:
            List of answer strings (e.g., ['93.184.216.34'])

        Raises:
            httpx.HTTPError: If the DoH server is unreachable
            dns.exception.DNSException: If the DNS response is malformed
        """
        # Build DNS query in wire format
        query = dns.message.make_query(domain, dns.rdatatype.from_text(record_type))
        wire_query = query.to_wire()

        # Send via HTTPS POST (RFC 8484 Section 4.1.1)
        response = self.client.post(
            self.url,
            content=wire_query,
            headers={"Content-Type": "application/dns-message"},
            timeout=10.0
        )
        response.raise_for_status()

        # Parse DNS response from wire format
        answer = dns.message.from_wire(response.content)
        results = []
        for rrset in answer.answer:
            for rdata in rrset:
                results.append(str(rdata))

        return results

# Usage
client = DoHClient("cloudflare")

# A record lookup
ips = client.resolve("example.com", "A")
print(f"example.com A records: {ips}")  # ['93.184.216.34']

# MX record lookup
mx = client.resolve("gmail.com", "MX")
print(f"gmail.com MX records: {mx}")    # ['5 gmail-smtp-in.l.google.com.', ...]

Client Configuration

Linux (systemd-resolved)

# /etc/systemd/resolved.conf
[Resolve]
DNS=1.1.1.1 1.0.0.1
DNSOverHTTPS=yes
DNSSEC=yes

sudo systemctl restart systemd-resolved

# Verify
resolvectl status

Firefox

about:config → network.trr.mode = 3 (Force DoH)
about:config → network.trr.uri = https://1.1.1.1/dns-query

Enterprise Architecture

flowchart LR
    subgraph Clients["Internal Clients"]
        C1[Workstation]
        C2[Mobile]
        C3[Server]
    end

    subgraph Enterprise["Enterprise Network"]
        DoH[Internal DoH Server<br/>Nginx + Unbound]
        Auth[Authentication<br/>Client cert / IP allowlist]
        Log[DNS Audit Log]
        Filter[DNS Filtering<br/>Policy enforcement]
    end

    subgraph Upstream["Upstream Resolvers"]
        CF[Cloudflare 1.1.1.1]
        G[Google 8.8.8.8]
        Q9[Quad9 9.9.9.9]
    end

    C1 -->|HTTPS:443| DoH
    C2 -->|HTTPS:443| DoH
    C3 -->|HTTPS:443| DoH
    DoH --> Auth --> Log --> Filter
    Filter --> CF
    Filter --> G
    Filter --> Q9

Resources

Comments

👍 Was this article helpful?