yum upgrades for production use, this is the repository for you.
Active subscription is required.
TLS 1.3 encrypts almost everything. Almost.
Every handshake still announces, in plaintext, the hostname you are asking for. That is Server Name Indication, and it is the reason your ISP, your employer’s middlebox, and whoever runs the coffee shop Wi-Fi can build a complete list of the sites you visit without decrypting a single byte of traffic. Encrypted DNS closed the DNS half of that leak years ago. SNI stayed open.
Encrypted Client Hello closes it. RFC 9849 went to Proposed Standard, and it is in our packages: ssl_ech_file works in stock NGINX, NGINX-MOD and EDGE, along with $ssl_ech_status and $ssl_ech_outer_server_name.
As far as we can tell we are the first RPM vendor shipping a server-side ECH-capable NGINX. If someone beat us to it, tell us and we will correct this.
How ECH actually works
The trick is two ClientHellos instead of one.
The outer ClientHello names a shared, public “cover” hostname. That is the only name an observer sees. Inside it, encrypted to a public key you publish in DNS, sits the real ClientHello with the real hostname. The server decrypts the inner one and routes accordingly.
The public key travels in the ech= parameter of an HTTPS resource record (RFC 9460):
secret.example.com. 300 IN HTTPS 1 . alpn="h3,h2" ech="AED+DQA8bAAgACDltwtL..."
Which immediately tells you the two things everyone gets wrong, so let us get them out of the way early.
The two things everyone gets wrong
Your DNS zone has to be DNS-only. If that HTTPS record sits behind a proxying CDN, the CDN is the TLS endpoint. It publishes its own HTTPS record with its own ECH keys. Clients get the CDN’s ECH, and your ssl_ech_file is never touched. That is not a bad outcome, since a large CDN has an enormous anonymity set, but it is the CDN’s privacy feature, not yours. To serve your own ECH the record must be 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 will not use ECH without DoH or DoT. Nothing you configure on the server changes that.
Be honest about what it buys you
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.
If you run multi-tenant hosting, a CDN, or any shared front end, that is a real and substantial gain. An observer learns that someone reached a box serving thousands of sites, and nothing else.
If you run one site on a dedicated IP, it buys you very little. The address alone identifies the site, and a reverse lookup or a certificate transparency search finishes the job in seconds. ECH still strips the SNI string, which has some value against crude keyword-matching censorship, but anyone selling you ECH as a privacy silver bullet for a single-tenant server is selling you something.
The privacy comes from the size of the crowd behind the public name. A cover name used by one site hides nothing.
Why OpenSSL 3.5 and not OpenSSL 4.0
ECH landed upstream in OpenSSL 4.0. We are not shipping OpenSSL 4.0.
Our TLS stack is openssl35, an ABI-isolated build of OpenSSL 3.5 LTS with a CVE stream tracked to April 2030. That LTS commitment is the whole point of it, and swapping the fleet onto a brand new major release to pick up one feature would throw it away. So we did it the other way round: backported the ECH implementation from the DEfO project onto 3.5 LTS, with the same API surface OpenSSL 4.0 exposes.
NGINX itself needed no patching at all. ssl_ech_file is upstream NGINX, compiled in automatically when the TLS library supports ECH. That is the nice thing about backporting to the library rather than patching the server: the feature arrives through the front door.
The same rebuild also carries EDGE over to openssl35, so our Ultra-tier server does not trail its own base on TLS.
What this looks like in your configuration
Almost nothing, which is the part worth spelling out.
nginx-ech-keygen writes the keys into an http-level include, /etc/nginx/conf.d/ech-keys.conf, which stock nginx.conf already picks up through include /etc/nginx/conf.d/*.conf;. Here is what it holds on our demo host right now:
# Generated by nginx-ech-keygen. Do not edit - rotation overwrites this file.
ssl_ech_file /etc/nginx/ech/ech-20260827T000001Z.pem; # advertised
ssl_ech_file /etc/nginx/ech/ech-20260826T000000Z.pem; # decrypt only
ssl_ech_file /etc/nginx/ech/ech-20260825T063759Z.pem; # decrypt only
ssl_ech_file is valid in both http and server context, so keys loaded once at http level apply to every TLS server on the box. That is broader than it sounds only on paper: ECH engages solely for a client that already holds an ECHConfigList for the name it is asking for, and a vhost that publishes no ech= record never sees one.
Which leaves your vhost with no ECH in it at all:
server {
listen 443 ssl;
listen 443 quic;
http2 on;
http3 on;
server_name secret.example.com;
ssl_certificate /etc/letsencrypt/live/secret.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/secret.example.com/privkey.pem;
# ECH requires TLS 1.3. Keep 1.2 so a non-ECH client still gets a page
# rather than a handshake failure.
ssl_protocols TLSv1.2 TLSv1.3;
# No ssl_ech_file here - the generated include already loaded the keys.
}
If you would rather not run the timer, write the directives yourself, in the same order – first one advertised, the rest decryption-only:
server {
server_name secret.example.com;
ssl_ech_file /etc/nginx/ech/ech-20260827T000001Z.pem; # advertised
ssl_ech_file /etc/nginx/ech/ech-20260826T000000Z.pem; # decrypt only
}
Then watch it work. $ssl_ech_status is the server’s own verdict on the handshake, which settles arguments no client-side screenshot can:
log_format ech '$remote_addr "$host" ech=$ssl_ech_status '
'outer=$ssl_ech_outer_server_name';
access_log /var/log/nginx/access.log ech;
SUCCESS means the real hostname travelled encrypted. GREASE means the client supports ECH but never received your HTTPS record, which is a DNS problem rather than a TLS one. To check it from a shell, read the key out of DNS and hand it to OpenSSL:
dig +short HTTPS secret.example.com
openssl35 s_client -connect secret.example.com:443 \
-servername secret.example.com -ech_config_list PASTE_THE_ech_VALUE
The full directive reference, DNS examples and rotation detail live in the ECH documentation.
Rotate your keys, and let something else do it
ECH keys are meant to be short-lived. Doing that by hand is miserable, because two facts fight each other:
NGINX reads ECH keys when it parses its configuration, so a new key does nothing until you reload. And a client that cached your HTTPS record an hour ago encrypted to the old key, so if you drop that key the moment you generate a new one, you break them.
NGINX handles the second half properly. ssl_ech_file can be repeated, and the order is load-bearing: the first file is what gets advertised in retry-configs, and every later one is kept for decryption only. That is exactly the shape a rotation scheme needs. So we packaged one:
dnf install nginx-mod-ech
vi /etc/sysconfig/nginx-ech-rotate # ECH_PUBLIC_NAME="ech.example.com"
nginx-ech-keygen --init
nginx-ech-keygen --print-dns # publish this in your HTTPS record
vi /etc/sysconfig/nginx-ech-publish # then let rotation republish it: ECH_PUBLISH_PROVIDER="cloudflare"
systemctl enable --now nginx-ech-rotate.timer
That is the whole setup. The timer rotates daily, keeps three generations loaded, shreds anything older, and regenerates /etc/nginx/conf.d/ech-keys.conf, the include NGINX already reads. Keys live in /etc/nginx/ech as 0640 root:nginx inside a 0750 directory, generated under umask 077 and renamed into place, so a private key is never briefly world-readable and NGINX never sees a half-written file.
Nothing runs until you set a public name, and the timer is not enabled on install. Installing the package changes nothing about a working server. EDGE gets the same thing as edge-ech, with EDGE’s own paths and unit names.
DNS republication is packaged too
The timer rotates the key, but the ECHConfigList a client encrypts to comes from your HTTPS DNS record, so left alone the record goes stale the first time the timer fires. Nothing breaks, which is exactly why it is easy to miss: stale clients get failed+retry-configs, connect anyway, and self-heal. But no first handshake ever gets ECH, and that is most of the benefit gone.
Since nginx-mod-ech 1.30.4-61 (and edge-ech 1.30.4-6), the package closes that loop itself. nginx-ech-publish runs as the second ExecStart of the rotation unit – Type=oneshot runs them in order, so it only fires after a rotation that actually succeeded – and republishes the new value in your HTTPS record. One sysconfig edit turns it on:
# /etc/sysconfig/nginx-ech-publish
ECH_PUBLISH_PROVIDER="cloudflare"
ECH_ZONE_ID="..."
ECH_RECORD_NAME="www.example.com" # the inner name clients visit, not the cover name
The Cloudflare provider reuses certbot’s /root/.cloudflare.ini credentials, updates the record in place, and treats a failed record lookup as a hard stop rather than "no record", so it cannot create duplicate HTTPS records. Providers are drop-in executables under /usr/libexec/nginx-ech-publish/, so adding Route 53, deSEC or any other DNS API is one script – no dispatcher changes. It ships inert: no provider set, nothing runs. To our knowledge no other NGINX package repository ships the rotate-reload-republish loop closed end to end.
For a provider without a drop-in yet, the manual hook still works: add your own ExecStart drop-in to the rotation unit and push what nginx-ech-keygen --print-dns prints at your provider’s API. Either way, retention covers the propagation gap: the previous key is still loaded for decryption while the record catches up.
And get the certificates right
The cover name’s certificate is presented in the outer handshake, in the clear, to the observer ECH exists to defeat.
So do not put the names you are hiding in its SAN list. One certificate that covers both the cover name and the secret name hands back precisely what ECH just concealed, and it will pass every test you run because the handshake works fine. Issue them separately.
What happens if you get the retention window wrong
We measured it rather than guessed, and the answer is reassuring.
| Client’s cached ECHConfigList | Result |
|---|---|
| The advertised key | ECH succeeds, routed to the inner name |
| A retained decrypt-only key | ECH succeeds, routed to the inner name |
| Aged out of the retention window | ECH: failed+retry-configs, connection still completes |
In the last case NGINX cannot decrypt, falls back to the outer ClientHello, serves the public name’s server block, and hands back a retry-config with the current key. The client picks it up and recovers by itself. You pay a wasted round trip, not an outage.
Which is also the strongest argument for getting your cover name’s certificate right. Every stale client lands on it. Get that certificate wrong and a routine rotation turns a recoverable retry into a certificate error your users actually see.
Try it against a real server
We put one up: ech-test.getpagespeed.com.
It runs this exact stack on a public 443, with keys rotated daily by the same nginx-mod-ech package described above, and it tells you what your browser actually negotiated. There is a JSON endpoint at /status.json if you want to script against it. The cover name it hides behind, ech.getpagespeed.com, is public too – that is the only hostname an observer of that connection can see.
Client support, stated honestly
Verified end to end against the demo host, over real DoH with a real ech= HTTPS record, reaching $ssl_ech_status success: Chrome / Chromium, Firefox 147, and openssl s_client -ech_config_list.
Firefox is worth a paragraph, because our first lab attempt saw it send GREASE only and we nearly wrote that up as an unknown. It was not an interop problem. It was DNS every time, and the causes are worth knowing before you conclude your server is broken:
- Firefox only uses an ECHConfig that arrived from a trusted recursive resolver. Turning on “DNS over HTTPS” is not sufficient by itself – a provider has to actually be selected. Mode set, provider empty, and Firefox resolves natively and sends GREASE.
- Firefox honours the operating system’s proxy configuration, PAC files included. If DoH gets routed through a proxy that will not carry it, TRR fails silently and everything falls back to native DNS.
curlandopenssl s_clienton the same machine are unaffected, which makes this one extremely convincing as a server-side bug. It is not. - A
hostsentry for the name means the ECHConfig is never fetched at all. - TRR answers containing RFC 1918 addresses are discarded by default, an anti-rebinding measure that throws away the HTTPS record along with them – so a private-IP lab host cannot work.
- On macOS, DoH has to be on for ECH to be used at all.
Point a browser at the demo host and you can tell a client-side DNS problem from a server-side one in about five seconds, which is the main reason it exists.
Clients with no ECH support are unaffected. They send an ordinary ClientHello and get served normally.
Limitations worth knowing
Split mode is not supported. NGINX implements shared mode, where the server that decrypts ECH also serves the inner name. The split arrangement, with a front end decrypting and forwarding to a separate backend, is not in upstream NGINX.
ECH requires TLS 1.3. There is no ECH for TLS 1.2.
And new keys need a reload. The rotation timer handles that for you, but it is worth knowing it is a reload and not a signal-free hot swap.
Getting it
ECH is in the main production repository now, in stock NGINX, NGINX-MOD and EDGE alike, with rotation tooling in nginx-ech, nginx-mod-ech and edge-ech respectively. A plain dnf upgrade nginx (or nginx-mod) from the GetPageSpeed Extras repository gets you the OpenSSL 3.5-linked build.
Debian and Ubuntu got the same builds in late August 2026. Every DEB suite we publish, Ubuntu 20.04 / 22.04 / 24.04 LTS and Debian 12 / 13 on amd64 and arm64, links the same openssl35, so apt-get install nginx-ech is the DEB equivalent of the line above. The rotation tooling is identical apart from its configuration paths, which follow Debian convention: /etc/default/nginx-ech-rotate and /etc/default/nginx-ech-publish rather than /etc/sysconfig/. The rest of that switch is covered in Post-Quantum NGINX: OpenSSL 3.5 on Debian and Ubuntu. Full configuration reference, DNS examples and rotation details are in the ECH documentation, and ech-test.getpagespeed.com will tell you whether your own browser can use any of it.
Two operational notes for the switch to the OpenSSL 3.5-linked builds. If you use a PKCS#11 HSM with NGINX, install openssl35-pkcs11 (it replaces quictls-pkcs11) and mirror your engine or provider configuration into /etc/pki/openssl35/openssl.cnf, which is the OPENSSLDIR the new library reads. FIPS posture is unchanged: these builds run the default OpenSSL provider even on fips=1 hosts, exactly as the QuicTLS-linked builds did before them.
If you run multi-tenant infrastructure, this is the most meaningful privacy improvement available to your users right now, and it costs you one directive and a DNS record.
