Skip to content

Encrypted Client Hello (ECH)

Encrypted Client Hello (RFC 9849) closes the last big plaintext leak in a TLS handshake: the Server Name Indication. Without it, every TLS 1.3 connection announces the hostname you are visiting in the clear, where any on-path observer can read it. ECH wraps the real ClientHello — hostname and all — inside an outer one that names only a shared, public "cover" hostname.

ssl_ech_file and the $ssl_ech_status / $ssl_ech_outer_server_name variables are available in nginx-mod and edge, which link against openssl35 (OpenSSL 3.5 LTS carrying the ECH backport). No patching of NGINX is involved; the directives come from NGINX itself and switch on when the TLS library supports ECH.

Try it before you build it

ech-test.getpagespeed.com runs this exact stack on a public 443, with keys rotated daily by the same nginx-mod-ech package documented below. It reports what your own browser negotiated, so you can tell a client-side DNS problem from a server-side one before touching your own configuration.

Does ECH actually help you?

Be honest with yourself about the threat model before deploying it.

ECH hides which name you asked for among the names sharing a server. It does not hide that you connected, and it does not hide the IP address.

  • Multi-tenant hosting, CDNs, shared front ends — real, substantial gain. An observer learns you reached a server hosting thousands of sites and nothing more.
  • One site on a dedicated IP — very little gain. The address alone identifies the site, and reverse DNS or a certificate transparency lookup finishes the job. ECH still removes the SNI string, which is worth something against crude keyword-matching censorship, but do not oversell it.

The privacy of ECH comes from the size of the anonymity set behind the public name. A cover name used by one site protects nothing.

Configuration

server {
    listen 443 ssl;
    listen 443 quic;
    http2 on;
    http3 on;

    server_name secret.example.com;

    ssl_certificate     /etc/pki/tls/certs/secret.example.com.crt;
    ssl_certificate_key /etc/pki/tls/private/secret.example.com.key;
    ssl_protocols TLSv1.3;

    ssl_ech_file /etc/nginx/ech/ech-20260825T000000Z.pem;
}

ECH is inert until ssl_ech_file is present, so adding the package changes nothing about an existing deployment.

ssl_ech_file is valid in both http and server context. Putting it at http level applies it to every TLS server that inherits it, which is normally what you want — and it is harmless for plain-HTTP servers, because NGINX only reads ECH keys for servers that have certificates.

Several keys, and why the order matters

ssl_ech_file may be repeated. The ordering is not cosmetic:

ssl_ech_file /etc/nginx/ech/ech-20260825T000000Z.pem;  # advertised
ssl_ech_file /etc/nginx/ech/ech-20260824T000000Z.pem;  # decrypt only
ssl_ech_file /etc/nginx/ech/ech-20260823T000000Z.pem;  # decrypt only

NGINX advertises only the first file in ECH retry-configs. Every later file is loaded for decryption only. That is what makes key rotation safe: a client that picked up an older ECHConfigList from a cached HTTPS record encrypted to an older key, and that key must still be in the store or the handshake fails.

Keys are read when NGINX parses its configuration, so a newly generated key does nothing until nginx -s reload.

The public name needs a certificate

The public_name baked into the ECHConfig is the cover hostname clients put in the outer, cleartext SNI. Pick one you control, point it at the same server, and make sure your certificate covers it. When a client's ECH attempt fails — stale key, mangled record, middlebox — it falls back to an ordinary handshake against that name, and a certificate error there is a hard failure your users will see.

Observing it

log_format ech '$remote_addr "$host" ech=$ssl_ech_status '
               'outer=$ssl_ech_outer_server_name';
access_log /var/log/nginx/access.log ech;

$ssl_ech_status reports the outcome of the ECH attempt for the connection. $ssl_ech_outer_server_name gives the cover name the client used, which is useful for confirming the client really is using your ECHConfig rather than GREASE.

Publishing the HTTPS DNS record

ECH is worthless until clients can find your ECHConfigList. It travels in the ech= parameter of an HTTPS resource record:

secret.example.com.  300  IN  HTTPS  1 . alpn="h3,h2" ech="AD7+DQA65wAg..."

nginx-ech-keygen --print-dns prints the exact ech="..." value for the currently advertised key.

Two requirements people get wrong

The zone must be DNS-only. If the record is behind a proxying CDN — an orange-cloud Cloudflare record, for instance — that provider terminates TLS and publishes its own HTTPS record with its own ECH keys. Clients get the provider's ECH, not yours; your ssl_ech_file is never exercised, because the provider is the TLS endpoint. That is not necessarily bad (the provider's anonymity set is enormous), but it is theirs, not yours. To serve your own ECH, the record must be grey-cloud / DNS-only.

Clients need encrypted DNS. An HTTPS record fetched over plaintext port 53 leaks the hostname to exactly the observer ECH is meant to defeat, so browsers only use ECH when the record arrived over DoH or DoT. Users without encrypted DNS get no ECH — nothing you configure on the server changes that.

Cloudflare example

curl -sS -X POST \
  "https://api.cloudflare.com/client/v4/zones/$ZONE_ID/dns_records" \
  -H "Authorization: Bearer $CF_API_TOKEN" \
  -H "Content-Type: application/json" \
  --data @- <<EOF
{
  "type": "HTTPS",
  "name": "secret.example.com",
  "ttl": 300,
  "proxied": false,
  "data": {
    "priority": 1,
    "target": ".",
    "value": "alpn=\"h3,h2\" $(nginx-ech-keygen --print-dns)"
  }
}
EOF

Keep the TTL short and comfortably below your key retention window, so a rotated-out key is never the only one a cached record points at.

Automatic key rotation

ECH keys are meant to be short-lived. The nginx-mod-ech package ships the tooling:

dnf install nginx-mod-ech

It provides nginx-ech-keygen, a nginx-ech-rotate systemd timer, and /etc/sysconfig/nginx-ech-rotate. Nothing runs until you configure it.

# 1. Set the cover hostname.
vi /etc/sysconfig/nginx-ech-rotate      # ECH_PUBLIC_NAME="ech.example.com"

# 2. Create the first key. Writes /etc/nginx/conf.d/ech-keys.conf and reloads.
nginx-ech-keygen --init

# 3. Publish what it prints in your HTTPS record.
nginx-ech-keygen --print-dns

# 4. Hand rotation to the timer (daily by default).
systemctl enable --now nginx-ech-rotate.timer
Command Effect
--init First key, generates the include, reloads NGINX
--rotate New key, retires anything past ECH_RETAIN, reloads
--print-dns The ech="..." value for the advertised key
--list Current keys, marked advertised / decrypt-only

On EDGE

The same tooling ships as edge-ech, rebranded to EDGE's own paths: edge-ech-keygen, the edge-ech-rotate timer, /etc/sysconfig/edge-ech-rotate, and keys under /etc/edge/ech. Every step above applies with the names substituted.

ECH_RETAIN (default 3) controls how many generations stay loaded. With daily rotation that is roughly a three-day grace window for clients holding a cached HTTPS record. Set it above your record's TTL, not below.

Keep DNS in step with rotation

The timer rotates the key and reloads NGINX. It does not touch DNS, and it cannot — it has no idea who hosts your zone. Left alone, the HTTPS record goes stale the first time the timer fires.

Nothing breaks: clients holding the stale ECHConfigList get ECH: failed+retry-configs, still connect via the cover name, and self-heal from the retry-config. But no first handshake ever gets ECH, which quietly throws away most of the benefit. Hook your provider onto the rotation unit:

# /etc/systemd/system/nginx-ech-rotate.service.d/50-publish-dns.conf
# Type=oneshot runs ExecStart lines in order, so this fires only after a
# successful rotation.
[Service]
ExecStart=/usr/local/sbin/publish-ech-dns

Have that script read nginx-ech-keygen --print-dns and PUT the value into the HTTPS record. Because rotation retains ECH_RETAIN generations, the gap between the reload and DNS propagation is covered by design — the previous key is still loaded for decryption.

Certificates: do not put the inner name on the cover name

The cover name's certificate is presented in the outer handshake, where the observer ECH exists to defeat can read it. If one SAN list covers both the cover name and the names you are hiding, that certificate hands back exactly what ECH just concealed.

Issue them separately: one certificate for the public name, one per inner name.

What a too-short window actually costs

Not an outage, as it turns out. We measured all three cases against these packages:

Client's cached ECHConfigList Result
The advertised key ECH succeeds, request routed to the inner name
A retained decrypt-only key ECH succeeds, request routed to the inner name
Aged out of ECH_RETAIN ECH: failed+retry-configs, connection still completes

In the third case NGINX cannot decrypt, so it falls back to the outer ClientHello, serves the public name's server block, and returns a retry-config carrying the current key. The client picks that up and recovers on its own. The cost is a wasted round trip, not a broken page.

This is also why the public name's certificate matters so much: every stale client lands on it. Get that certificate wrong and a rotation turns a recoverable retry into a certificate error.

/etc/nginx/conf.d/ech-keys.conf is generated on every rotation. Do not edit it; it is rewritten to match whatever is on disk. Keys live in /etc/nginx/ech, mode 0640 root:nginx, in a 0750 directory — the NGINX master parses the configuration as root, so workers never need to read them.

Rotating more often than daily is reasonable and is what the ECH designers suggest, but remember each rotation costs an nginx -s reload. Override with a drop-in rather than editing the shipped unit:

systemctl edit nginx-ech-rotate.timer    # [Timer] / OnCalendar=hourly

Client support

Verified against these packages, end to end, over DoH against a real ech= HTTPS record, reaching $ssl_ech_status success:

Client Result
Chrome / Chromium ECH negotiated
Firefox 147 ECH negotiated
openssl35 s_client -ech_config_list ECH negotiated

Clients that do not support ECH are unaffected: they send a normal ClientHello and NGINX serves them normally.

You can check any client against our public demo host, ech-test.getpagespeed.com — it reports what your browser actually negotiated, and serves the same data as JSON at /status.json.

When a browser that supports ECH does not use it

Almost always DNS, not TLS. Firefox in particular declines to use ECH in several situations a test setup falls into easily, and each one looks like an interop failure while being nothing of the sort. Ours reported GREASE for a while for exactly this reason.

  • The HTTPS record was not fetched over DoH. Firefox only uses an ECHConfig that came from a trusted recursive resolver (TRR). Enabling "DNS over HTTPS" is not enough on its own — a provider has to be selected. With network.trr.mode set but network.trr.uri empty, Firefox resolves natively and sends GREASE.
  • A system proxy is in the way. Firefox honours the OS proxy configuration by default, including a PAC file. If DoH is routed through that proxy and the proxy will not carry it, TRR silently fails and every lookup falls back to native DNS — or hangs. curl and openssl s_client on the same machine are unaffected, which makes this one very convincing as a "server bug".
  • A hosts entry covers the name. A name resolved that way never gets its ECHConfig fetched at all.
  • The address is RFC 1918. TRR answers containing private addresses are discarded by default (network.trr.allow-rfc1918) as an anti-DNS-rebinding measure — which discards the HTTPS record carrying ech= along with them.
  • On macOS, DoH must be on for ECH to be used at all.

Test against a publicly resolvable name pointing at a public address, with a DoH provider explicitly selected, no proxy, and no hosts entry. about:networking#dns shows whether the answer came from TRR.

Limitations

  • Split mode is not supported. NGINX implements the shared-mode server, where the ECH-terminating server also serves the inner name. Split mode, in which a front end decrypts ECH and forwards to a separate backend, is not in upstream NGINX.
  • ECH needs TLS 1.3. There is no ECH for TLS 1.2 connections.
  • A reload is required for new keys to take effect.

See also