Deploying PQC on Edge: Nginx and Envoy Configurations
TL;DR
Hybrid PQC support at the edge is bounded entirely by which TLS library your proxy is linked against, not by
the proxy itself. Nginx inherits whatever OpenSSL provides — native
ML-KEM hybrid groups since OpenSSL 3.5, only via
the Open Quantum Safe oqs-provider plugin before that. Envoy inherits whatever BoringSSL provides, which has
carried hybrid groups since Chrome's 2023 draft rollout and now speaks the standardized
X25519MLKEM768. In TLS 1.3, key exchange group and cipher suite are negotiated independently —
ssl_ciphers in nginx controls only the AEAD suite (TLS_AES_256_GCM_SHA384 and similar) and has no effect
on which key exchange group is offered; ssl_ecdh_curve (or the more portable ssl_conf_command Groups
passthrough) is the actual directive that adds X25519MLKEM768 to the negotiation. Verify what actually
happened on the wire with openssl s_client -groups, not by assuming the config took effect.
Where PQC Support Actually Lives: OpenSSL and BoringSSL
OpenSSL. Versions 3.0 through 3.4 have no built-in ML-KEM support. Post-quantum key exchange on those
versions requires the Open Quantum Safe project's oqs-provider, loaded through OpenSSL 3.x's provider
architecture and referenced by its own group names — which do not match the now-standardized names. A
deployment built against oqs-provider before standardization and one built against a current OpenSSL are
not interchangeable without re-pinning the group list. OpenSSL 3.5 (April 2025, LTS) changed this by adding
native ML-KEM support directly in libssl: hybrid groups X25519MLKEM768, SecP256r1MLKEM768, and
SecP384r1MLKEM1024 are available with no external provider at all. Check what you're actually running with:
openssl version
nginx -V 2>&1 | grep -o 'OpenSSL [0-9.]*'BoringSSL. Google's fork has no numbered releases — Envoy and Chrome each vendor a pinned commit and
rebuild against it. BoringSSL has carried hybrid PQC key exchange since Chrome's original 2023 rollout of the
pre-standard draft group and now implements the finalized X25519MLKEM768. There is no separate package or
provider to install: if Envoy's vendored BoringSSL commit is recent, hybrid key exchange is already compiled
in, gated only by configuration.
The practical consequence for an infrastructure team: your PQC ceiling is set at build time, by whichever TLS library your proxy binary links against, not by an nginx or Envoy version number in isolation. Confirm the underlying library version before assuming a config change will do anything.
Enabling Hybrid Key Exchange in Nginx
TLS 1.3 negotiates the key exchange group and the AEAD cipher suite as two independent choices — a departure
from TLS 1.2's monolithic cipher suite names that bundled key exchange, authentication, encryption, and MAC
together. ssl_ciphers in nginx only ever controlled the encryption/MAC half of that; it was never the
mechanism for key exchange, in TLS 1.2 or TLS 1.3, and adding a PQC group name to ssl_ciphers has no effect
— it will simply be ignored or rejected, not silently do nothing useful. The directive that actually controls
key exchange group negotiation is ssl_ecdh_curve:
server {
listen 443 ssl;
server_name example.com;
ssl_protocols TLSv1.3;
ssl_certificate /etc/nginx/certs/example.com.crt;
ssl_certificate_key /etc/nginx/certs/example.com.key;
# Order is preference: hybrid PQC first, classical X25519 and P-256
# as fallback for clients without ML-KEM support.
ssl_ecdh_curve X25519MLKEM768:X25519:prime256v1;
# ssl_ciphers controls AEAD suite selection only — independent of
# the key exchange group set above.
ssl_ciphers TLS_AES_256_GCM_SHA384:TLS_CHACHA20_POLY1305_SHA256;
location / {
proxy_pass http://backend;
}
}ssl_ecdh_curve works here because nginx passes its value straight through to OpenSSL's generic group-list
API — the same function that already handled X25519 despite the directive's legacy "ecdh_curve" name. On a
build where the directive parser predates hybrid group name support, or when running against
oqs-provider-patched OpenSSL rather than 3.5's native support, the more portable form passes the list
directly to OpenSSL's config command interface instead:
ssl_conf_command Groups X25519MLKEM768:X25519:prime256v1;Both forms terminate at the same underlying OpenSSL API; use whichever your nginx build's documentation confirms is wired up, and verify the result rather than assuming either succeeded silently.
Enabling Hybrid Key Exchange in Envoy
Envoy configures TLS through its DownstreamTlsContext (or UpstreamTlsContext for egress), setting the
group preference list in tls_params.ecdh_curves:
transport_socket:
name: envoy.transport_sockets.tls
typed_config:
"@type": type.googleapis.com/envoy.extensions.transport_sockets.tls.v3.DownstreamTlsContext
common_tls_context:
tls_params:
tls_minimum_protocol_version: TLSv1_3
tls_maximum_protocol_version: TLSv1_3
ecdh_curves:
- X25519MLKEM768
- X25519
tls_certificates:
- certificate_chain:
filename: "/etc/envoy/certs/example.com.crt"
private_key:
filename: "/etc/envoy/certs/example.com.key"Because Envoy vendors BoringSSL rather than linking a system OpenSSL, there is no equivalent to nginx's
provider-versus-native split to worry about — if the vendored BoringSSL commit backing your Envoy release
supports X25519MLKEM768, this configuration is sufficient on its own.
Testing the Deployment
Configuration changes are unverified until confirmed on the wire — a typo in a group name is usually accepted silently and just falls back to whatever's next in the list.
openssl s_client — the most direct check, forcing the group list and inspecting the handshake:
openssl s_client -connect example.com:443 -tls1_3 -groups X25519MLKEM768:X25519The session output includes the negotiated key exchange group in its handshake summary. Restricting -groups
to only X25519MLKEM768 (dropping the fallback) turns this into a strict test: the handshake succeeds only if
the server actually offers the hybrid group, and fails cleanly if it doesn't.
curl — useful for confirming behavior against the actual HTTP path rather than a bare TLS handshake:
curl --curves X25519MLKEM768 -v https://example.com/--curves sets curl's preferred group list the same way -groups does for openssl s_client; the depth of
handshake detail in curl's verbose output depends on the TLS backend curl itself was built against, so treat
openssl s_client as the authoritative check and curl as the application-layer sanity check.
bssl — BoringSSL's own bundled client, useful specifically because it's the reference implementation
X25519MLKEM768 originated from:
bssl client -connect example.com:443 -curves X25519MLKEM768BoringSSL kept the term "curves" in its own tooling even for non-elliptic-curve groups like ML-KEM, where OpenSSL's newer CLI has shifted to calling the same concept "groups" — the same underlying negotiation, different vocabulary depending on which library's tooling you're holding.
Rollout Considerations
Payload growth is the main operational risk worth watching post-deployment, not handshake failure — a hybrid
key_share runs roughly larger than a classical
X25519-only exchange. On an edge fleet terminating large connection volumes, monitor handshake latency and
retransmission rates after enabling hybrid groups, not just successful negotiation counts; the config change
described here is necessary but not sufficient for a clean rollout without the TCP-level tuning covered
separately. Treat the group list itself as part of your
crypto-agile configuration surface — pin it explicitly in
version control rather than trusting a package upgrade's new defaults, the same discipline that applies to
Go's CurvePreferences.