Disclosure: Some links on this site are affiliate links. If you purchase through them, we earn a small commission at no extra cost to you. Learn more

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:

nginx
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:

nginx
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:

yaml
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:X25519

The 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 X25519MLKEM768

BoringSSL 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 37×37\times 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.