· 6 min read

Three walls between you and post-quantum TLS on stock Ubuntu nginx

Enabling X25519MLKEM768 on Ubuntu 24.04 with oqs-provider sounds like a one-line change. It is not. A field report on the three non-obvious blockers.

Enabling X25519MLKEM768 on Ubuntu 24.04 with oqs-provider sounds like a one-line change. It is not. A field report on the three non-obvious blockers.

I got curve=X25519MLKEM768 to show in a curl trace against my own origin after an evening of debugging. Three things stood in the way — none documented in the nginx or oqs-provider READMEs, each a failure that completes silently and falls back to classical X25519 with no error logged.

Stack: Ubuntu 24.04 LTS, nginx 1.29.8 from the nginx.org mainline repo, Let’s Encrypt certificates, behind Akamai.

Goal: get the origin and a direct client to negotiate X25519MLKEM768 as the key exchange group.

If you’re hitting unexplained X25519 fallback regardless of what you put in ssl_ecdh_curve, one of these is likely the cause.

Why bother

The threat model is “harvest now, decrypt later”: record encrypted traffic today, decrypt it once a capable quantum computer exists. The fix is hybrid key exchange — combine X25519 with ML-KEM-768 so that breaking the session requires breaking both algorithms. The IETF has standardised this combination as X25519MLKEM768, NIST standardised the underlying algorithm in FIPS-203, and Chrome and Firefox negotiate it by default.

The algorithm is implemented in oqs-provider. The problem is wiring it correctly through a standard Ubuntu package stack.

The stack in one diagram

The three walls between a stock Ubuntu nginx and a working post-quantum TLS handshake Stack diagram: Client to Akamai edge to Akamai parent, then down to origin server. Inside the origin: nginx workers and OpenSSL 3.0.13 containing default provider and oqsprovider. Three numbered orange markers show where silent TLS fallback occurs. Where the three walls live Ubuntu 24.04 / nginx 1.29.8 / OpenSSL 3.0.13 / oqs-provider 0.7.0 Client Chrome / curl Akamai edge TLS termination Akamai parent Tiered distribution ClientHello: PQ + classical 3 Post Quantum Cryptography to Origin (pqcOrigin) Origin server systemd unit, nginx workers, OpenSSL libs nginx workers env from systemd, not shell 2 OpenSSL 3.0.13 default X25519, P-256 oqsprovider ML-KEM hybrid TLS group registry [ssl_sect] system_default Groups 1 Silent failure points — handshake completes, just not as PQC
  • Wall 1 is inside OpenSSL: getting the provider loaded and its groups registered in the TLS layer.
  • Wall 2 is at the nginx worker boundary: getting the right environment variables into worker processes at startup
  • Wall 3 is outside the origin: the Akamai parent-to-origin leg has its own enabling behavior.

Wall 1 — OpenSSL loads the provider but does not expose its groups

Ubuntu 24.04 ships OpenSSL 3.0.13. The oqs-provider builds cleanly: oqsprovider.so lands at /usr/lib/x86_64-linux-gnu/ossl-modules/oqsprovider.so and openssl list -providers reports it activated. The handshake still returns X25519.

The issue is that OpenSSL does not automatically expose a provider’s groups to the TLS layer. Two things must be configured in /etc/ssl/openssl.cnf, neither present on a stock Ubuntu install:

  1. The provider must be activated under [provider_sect].
  2. The TLS group list must be declared under [ssl_sect] and [system_default_sect].

Minimum config:

# /etc/ssl/openssl.cnf — add before the existing [default] section
openssl_conf = openssl_init

[openssl_init]
providers = provider_sect
ssl_conf  = ssl_sect

[provider_sect]
default     = default_sect
oqsprovider = oqsprovider_sect

[default_sect]
activate = 1

[oqsprovider_sect]
activate = 1
module   = /usr/lib/x86_64-linux-gnu/ossl-modules/oqsprovider.so

[ssl_sect]
system_default = system_default_sect

[system_default_sect]
Groups = X25519MLKEM768:X25519:secp256r1:secp384r1

Most guides cover [provider_sect]. The [ssl_sect] block is what registers X25519MLKEM768 with the TLS negotiation layer. Without it, the provider is loaded and the algorithm is available but the group never appears in a ServerHello.

A typo in this file produces no error at nginx startup — just silent fallback to X25519. I lost an evening to a bad character in the module path. The provider silently failed to load, nginx came up clean, and every handshake selected X25519. The only signal was openssl list -providers showing oqsprovider absent. That command is now a required smoke test after any openssl.cnf change.

Also useful: openssl list -kem-algorithms -provider oqsprovider should include X25519MLKEM768. If it does not, nginx will not find it either.

Side note on OpenSSL 3.0.x scope: it has rough edges around provider-based signature algorithms in TLS 1.3, but for KEM key exchange — which is what we need — 3.0.13 works. PQ signatures in TLS require OpenSSL 3.2+.

Wall 2 — nginx worker processes do not see the provider environment

Provider loaded, group registered, ssl_ecdh_curve X25519MLKEM768:X25519:secp256r1; set in the site config. Reload nginx, test — still X25519.

The root cause is environment isolation: worker processes do not inherit variables from the shell or from a standard systemd service environment. OPENSSL_CONF and OPENSSL_MODULES are absent unless explicitly provided. The provider fails to initialise in workers, TLS falls back gracefully, no error is logged.

The fix is nginx’s env directive with explicit values, added at the top level of nginx.conf before the events block. The env NAME=value form sets the variable unconditionally and propagates it to all worker processes on fork — unlike a bare env VARNAME;, which only passes through a variable already present in the master’s environment:

# /etc/nginx/nginx.conf — top level
env OPENSSL_MODULES=/usr/lib/x86_64-linux-gnu/ossl-modules;
env OPENSSL_CONF=/usr/lib/ssl/openssl.cnf;

Note the OPENSSL_CONF path: /usr/lib/ssl/openssl.cnf is Debian’s canonical symlink to /etc/ssl/openssl.cnf. Either path works, but the symlink is what Debian tooling uses by default.

No systemd drop-in required. The two env lines are the entire fix. After systemctl restart nginx, verify the variables reach a worker:

cat /proc/$(pgrep -fo 'nginx: worker')/environ | tr '\0' '\n' | grep OPENSSL

If nothing appears, the workers cannot see the provider config and nothing else will help until this is corrected.

Wall 3 — the parent-to-origin leg needs its own enablement

Past walls one and two, a direct curl confirms X25519MLKEM768. Then you tail the TLS audit log:

2.18.178.209  "HEAD / HTTP/1.1" 200 curve=X25519          sni=www.<redacted>
2.22.30.5     "HEAD / HTTP/1.1" 200 curve=X25519          sni=www.<redacted>
2.20.185.26   "GET  / HTTP/2.0" 200 curve=X25519MLKEM768  sni=www.<redacted>
23.200.84.167 "HEAD / HTTP/1.1" 200 curve=X25519          sni=www.<redacted>
2.20.185.26   "GET  /blog/... HTTP/2.0" 200 curve=X25519MLKEM768 sni=www.<redacted>
2.20.185.26   "GET  /sitemap-index.xml HTTP/2.0" 200 curve=X25519MLKEM768 sni=www.<redacted>
2.19.193.117  "HEAD / HTTP/1.1" 200 curve=X25519          sni=www.<redacted>

Two populations. The HEAD / requests against www.<redacted> are Akamai GTM liveness probes (FirstFlow agent in the access log) — they run a minimal TLS handshake to verify the box is alive and negotiate classical X25519. The GET requests against www.<redacted> are real traffic forwarded by the Akamai parent — every one shows X25519MLKEM768. This is expected.

The parent-to-origin leg is controlled by the Post Quantum Cryptography to Origin behavior (identifier: pqcOrigin) in Akamai Property Manager. When I first set this up it was in Limited Availability. It went GA in Q1 2026 and is rolling out as the default for Enhanced TLS properties.

One detail worth knowing: by default pqcOrigin sends classical X25519 in the first ClientHello, not the hybrid key. This avoids a HelloRetryRequest round-trip for origins that do not support PQC. Since our origin does support it after walls one and two, enable PQC Keys in First ClientHello in the behavior to avoid that extra round-trip.

End-to-end PQC across Akamai is three separate handshakes: client to edge, edge to parent, parent to origin. Walls one and two cover the last leg. The first two are controlled at the CDN layer.

Verifying

Direct curl against the origin:

curl -v --curves X25519MLKEM768 https://your-origin.example.com/ 2>&1 \
  | grep -i 'Curve Group\|SSL connection'

Expected output contains [Curve Group] X25519MLKEM768. Also confirm classical fallback works:

curl -v --curves X25519 https://your-origin.example.com/ 2>&1 | grep 'Curve Group'

For continuous visibility, add a dedicated nginx log format that captures the negotiated curve per request:

log_format tls_audit '$remote_addr - $remote_user [$time_local] '
                     '"$request" $status '
                     'tls=$ssl_protocol cipher=$ssl_cipher '
                     'curve=$ssl_curve sni=$ssl_server_name';

access_log /var/log/nginx/tls_audit.log tls_audit;

This is the format behind the log excerpts in this post. It is the only reliable way to confirm what the stack is actually negotiating on each request, as opposed to what the config is supposed to produce.

Conclusion

The stock Ubuntu 24.04 path works: nginx 1.29.8 mainline, OpenSSL 3.0.13, oqs-provider 0.7.0 loaded at runtime. Both origin nodes on this site have run this configuration through reboots, certbot renewals, and unattended-upgrades cycles without regression.

All three walls are the same problem in different layers: TLS stacks degrade gracefully. A missing provider group, a stripped worker environment, an unconfigured CDN behavior — none produces an error. Each produces a clean handshake at a weaker key exchange than intended. Log the negotiated curve explicitly. Do not assume that HTTPS working means the right key exchange was used.

Back to Blog