Secure DNS and web policy ยท Part 2

The DNS Security Policy

The DNS policy layer behind consistent web security

A, AAAA and HTTPS records enter a DNS policy that selects direct, inspected or blocked traffic
Martin Stecher Martin Stecher October 14, 2026

Part 1 protected the path to the resolver. This part is about what the resolver answers, and the most interesting answer today is the HTTPS record. It tells a browser how to connect before it sends a single request. Many security teams' first reaction is to block it. We think that is the wrong instinct. The record is a useful control channel, as long as your policy is the one writing it.

What an HTTPS record says

A and AAAA records answer a single question: which addresses belong to this name? The HTTPS record from RFC 9460 (type 65, the HTTP flavor of SVCB) also says which protocols to use, on which port, and how to encrypt the TLS ClientHello. Here is an abridged record:

cloudflare-ech.com.  IN  HTTPS  1 . alpn=h3,h2
                                 ipv4hint=104.18.10.118,104.18.11.118
                                 ech=AEX+DQBBywAgACBLJ9oE24G/...ZWNoLmNvbQAA
                                 ipv6hint=2606:4700::6812:a76,2606:4700::6812:b76

Read from left to right: priority 1, target . (this name itself), HTTP/3 and HTTP/2, address hints, and an Encrypted ClientHello (ECH) configuration. A browser that receives this record can go straight to HTTP/3 without first loading the page over HTTP/2 and waiting for an Alt-Svc header, and it can hide the real server name inside an encrypted ClientHello. Both are real gains in performance and privacy, so removing the record type from enterprise DNS has a cost.

Why passing it through breaks inspection

Hand that same record unchanged to a client whose traffic should be inspected, and each parameter causes trouble:

  • ech hides the server name the gateway routes by and issues its certificate for. And the gateway cannot decrypt a ClientHello encrypted to Cloudflare's key.
  • alpn=h3 sends the browser to QUIC over UDP. If the gateway or the firewall is not ready for that, the result is failed attempts and fallback delays. In the worst case it opens a route around inspection, as shown in Two Browsers, One URL – Completely Different Results.
  • ipv4hint and ipv6hint point at the origin, even though the A record may already point at the gateway.

The browser does nothing wrong here. DNS told it about a path that the rest of the security stack assumes does not exist.

Why blocking them is too blunt

Refusing all HTTPS queries is a reasonable emergency fix for a gateway that cannot see HTTP/3. As a permanent setting, it applies the same downgrade to every site and every client. There is no HTTP/3 even where the gateway supports it, no ECH even for traffic that policy sends direct, and protocol discovery gets slower. It does not even close the door completely, because Alt-Svc headers and cached state still advertise HTTP/3 on later connections. And sites that use the HTTPS record to steer clients to a preferred endpoint, protocol or address may behave differently, or simply worse, when it is missing.

The better approach is to rewrite instead of delete: decide the route per hostname, then answer with a record that describes that route truthfully.

Proxy PAC files are still the most common way to send enterprise browsers to a proxy, even though they are notoriously complex and their configuration errors regularly turn into security gaps. A policy-controlled HTTPS record may be the smarter way.

How the gateway path works

No proxy settings are involved. For a name selected for inspection, the resolver answers A, AAAA and HTTPS with the gateway's addresses. The browser connects to the gateway as if it were the origin and sends the real hostname in the SNI. The gateway presents a certificate for that name, issued on the fly by the organization's signing CA that managed devices trust. Behind that, it applies web policy and connects to the real origin.

So the HTTPS record on this path has to describe the gateway: its addresses, the protocols it serves, and no ech, because nothing on this path could use the origin's key. For the same name, Web Gateway answers:

cloudflare-ech.com.  IN  A      192.0.2.10
cloudflare-ech.com.  IN  AAAA   2001:db8::10
cloudflare-ech.com.  IN  HTTPS  1 . alpn=h3 port=443 ipv4hint=192.0.2.10
cloudflare-ech.com.  IN  HTTPS  2 . alpn=h2 port=443 ipv4hint=192.0.2.10

The resolver does not strip fields from the origin's record. It builds a new, coherent record for the service the client will actually reach.

One decision for A, AAAA and HTTPS

Browsers ask for A, AAAA and HTTPS in parallel. If each question were decided independently, timing and cache state could produce a gateway address in one answer and an HTTPS record pointing at the origin in another. A policy resolver needs one decision per hostname, applied to all three questions.

Policy outcomeDNS answerResulting web path
Origin
e.g., banking sites
The origin's current A, AAAA and HTTPS records, ECH included. The browser connects directly to the origin.
Gateway
Traffic selected for inspection
Gateway addresses and a gateway HTTPS profile. The browser connects through Web Gateway.
Block
Malicious or disallowed domains
NXDOMAIN or REFUSED, for every query type. No connection is started.
Default The locally configured DNS behavior. Static and policy-managed DNS coexist.
DNS policy combines hostname classification and client capability to return origin, gateway or blocked answers
One route decision per hostname: origin, gateway or block. Client context, covered below, only adjusts the protocol offer on the gateway path.

In Cloud Fellows' Web Gateway the decision itself is a few lines of Python. categorize() stands for whatever classification source you use, whether that is a local list, a library or an external reputation service:

def on_dns_query(ctx):
    category = categorize(ctx["hostname"])

    if category == "banking":
        return {"status": "origin", "cache_ttl": 1800}
    if category in ("malware", "phishing"):
        return {"status": "nxdomain", "cache_ttl": 300}
    return {"status": "gateway"}

The result is cached per hostname, and when A, AAAA and HTTPS for a new name arrive at the same moment, they share one policy call. origin forwards the question to the upstream resolver, so a direct site keeps its own record, ECH and all.

DNS only sees the hostname, not the URL. That makes it the right place for routing and early blocking, but not a replacement for web policy. And a DNS block is final but unfriendly: the user sees a browser error. For categories where an explanation helps, route the name to the gateway instead and let the HTTP policy block it with a proper block page.

A different offer for Safari and Chrome

Chromium refuses HTTP/3 when the server certificate chains to a locally installed root. This is by design: the QUIC code path keeps a separate exception list for unknown roots, only a developer command-line switch fills it, and no enterprise policy changes it. The same certificate works over HTTP/2. Safari accepts it over HTTP/3 as well.

That leaves a choice. Advertise h3 to Chrome on the gateway path, and every new connection costs a failed QUIC handshake: in our tests Chrome 154 aborted with ERR_QUIC_CERT_ROOT_NOT_KNOWN and reached HTTP/2 after at most one handshake round trip. Do not advertise it, and Safari loses HTTP/3.

DoH lets the resolver tell the two apart. Chrome's DoH client sends exactly User-Agent: Chrome, without version or platform. The macOS system resolver, which Safari uses, sends no User-Agent at all. A more robust signal is the separate DoH port for Chrome from Part 1, because it depends on your configuration rather than on a header. Two gateway profiles cover both cases:

"GatewayProfiles": {
    "default": {
        "A": "192.0.2.10", "AAAA": "2001:db8::10",
        "HTTPS": [
            { "Protocol": "H3", "Address": "192.0.2.10", "Port": 443 },
            { "Protocol": "H2", "Address": "192.0.2.10", "Port": 443 }
        ]
    },
    "h2-only": {
        "A": "192.0.2.10", "AAAA": "2001:db8::10",
        "HTTPS": [
            { "Protocol": "H2", "Address": "192.0.2.10", "Port": 443 }
        ]
    }
},
"CacheVary": ["hostname", "listener_port", "user_agent"]

The last line of the policy picks the profile:

CHROME_DOH_PORT = 8443

def on_dns_query(ctx):
    ...
    chromium = ctx["listener_port"] == CHROME_DOH_PORT or ctx.get("user_agent") == "Chrome"
    return {"status": "gateway", "gateway_profile": "h2-only" if chromium else "default"}

This is not authentication, and it does not need to be. A misclassified client still goes through the gateway. At worst it gets the other protocol offer and falls back. CacheVary makes sure a decision cached for Safari is not served to Chrome.

Tested September 2026 with Safari and the system resolver on macOS 12, Chrome 150 on macOS 12 and Chrome 154 on macOS 26. Firefox, Edge, iOS and Windows have not been verified yet.

Cloud Fellows' Web Gateway 1.1.0 model

  • DNS over UDP, TCP, TLS, HTTPS and QUIC from clients, and DoT, DoH or DoQ to the upstream resolver (Part 1).
  • A Python policy selects origin, gateway, nxdomain, refused or the configured default.
  • Parallel A, AAAA and HTTPS questions share one cached decision.
  • Gateway answers use named HTTPS profiles, for example HTTP/3 plus HTTP/2, or HTTP/2 only.
  • The policy sees the DNS transport, client address, query type, listener port and, for DoH, the User-Agent.