Skip to content
Migration engineering

Enabling post-quantum TLS: OpenSSL, nginx, and Go

9 min read

The term on this page

TLSTransport Layer Security
the protocol behind the padlock in your browser

Also mentioned

ML-KEMML-KEMModule-Lattice-based Key Encapsulation MechanismModule-Lattice-Based Key-Encapsulation Mechanism, the NIST-standardized post-quantum KEM derived from CRYSTALS-Kyber and specified in FIPS 203.Read the full entry (new tab), ML-DSAML-DSAModule-Lattice-based Digital Signature AlgorithmModule-Lattice-Based Digital Signature Algorithm, the NIST-standardized post-quantum signature scheme derived from CRYSTALS-Dilithium and specified in FIPS 204.Read the full entry (new tab), SLH-DSASLH-DSAStateless Hash-based Digital Signature AlgorithmStateless Hash-Based Digital Signature Algorithm, the NIST-standardized signature scheme derived from SPHINCS+ and specified in FIPS 205.Read the full entry (new tab), FIPSFIPSFederal Information Processing StandardFederal Information Processing Standards, publicly announced standards developed by NIST for use in U.S. government computer systems, including cryptographic algorithms and modules.Read the full entry (new tab) are defined in the glossary.

TL;DR

Read this first

On current stacks this is usually a verification job rather than a configuration job: OpenSSL 3.5 and Go 1.24 both offer X25519MLKEM768 by default. The two things that actually go wrong are an explicit configuration that silently turns it back off, and a middlebox that cannot cope with a ClientHello spanning more than one TCP segment. Check what you negotiate today before you change anything.

Check before you configure

The most common outcome of this task is discovering it was already done. Start by asking the endpoint what it negotiates, rather than reading your config and inferring.

  • From the command line: openssl s_client -connect example.com:443 -tls1_3 and look at the negotiated group in the output. You need an OpenSSL 3.5 or newer client for it to offer the hybrid group in the first place.
  • From the browser, one domain at a time: the post-quantum TLS check walks through reading the result.
  • Across an estate: qScan reads the configuration, and qProbe handshakes the endpoints you own.

If the answer is X25519MLKEM768, you are done for that endpoint and the work is elsewhere. If it is X25519 or P-256, read on.

OpenSSL 3.5 and newer

OpenSSL 3.5 changed the default TLS supported-groups list to include and prefer hybrid post-quantum groups, and changed the default key shares to offer X25519MLKEM768 alongside X25519. The default list is:

  • ?*X25519MLKEM768 / ?*X25519:?secp256r1 / ?X448:?secp384r1:?secp521r1 / ?ffdhe2048:?ffdhe3072
Pitfall

The line that turns it off

If your application or config file sets the group list explicitly, you have overridden that default, and unless you added X25519MLKEM768 yourself you have disabled post-quantum key exchange. This is by far the most common reason a stack that should negotiate the hybrid group does not. An explicit groups line written in 2021 is now a downgrade.

nginx

nginx exposes the group list through ssl_ecdh_curve, which sets the TLS 1.3 supported_groups extension. Put the hybrid first and keep classical fallbacks after it, so clients that cannot do ML-KEM still connect:

  • ssl_protocols TLSv1.2 TLSv1.3;
  • ssl_ecdh_curve X25519MLKEM768:X25519:prime256v1;

The fallbacks are not optional politeness. A client that does not offer ML-KEM must still be able to complete a handshake, so a group list containing only the hybrid will lock out everything old. Hybrid means secure if either half holds, and the deployment should mean reachable if either half is supported.

Pitfall

nginx fails silently on an old OpenSSL

nginx needs to be built against OpenSSL 3.5 or newer for this to do anything. Linked against something older, it does not error on an unrecognised group name, it ignores it. The config looks correct, nginx reloads cleanly, and nothing changes. Check nginx -V for the OpenSSL version it was actually compiled against, not the one your package manager reports.

To confirm from the server side, nginx exposes $ssl_curve, which you can log or pass to an application to see the group each client actually negotiated. That turns the rollout into a measurement rather than a hope.

Go

Go 1.24 added X25519MLKEM768 and enabled it by default, sending X25519MLKEM768 and X25519 key shares, for both clients and servers. There is nothing to switch on.

There is, however, something not to do. The default applies only when Config.CurvePreferences is nil. Setting CurvePreferences for any reason, including a well-intentioned hardening pass, replaces the default list and drops the hybrid group unless you name it explicitly. The GODEBUG setting tlsmlkem=0 also reverts the default, which is worth grepping your deployment environment for. If you build in FIPS mode, verify the behaviour rather than assuming it matches the standard build.

The failure you should expect

A hybrid key share is large. Adding ML-KEM-768 to a classical share puts roughly an extra kilobyte into the ClientHello, which frequently pushes it past a single TCP segment. Before post-quantum key exchange, a ClientHello spanning two segments was rare enough that a lot of deployed middleware quietly assumed it never happened.

So some middleboxes, load balancers and TLS-inspecting appliances read only the first segment, fail to find the end of a message they assumed would arrive whole, and either hang or drop the connection. The failure is not in your server and not in the client. It is in something between them, and it presents as a small, stubborn percentage of connections that fail while everything else works. When Cloudflare rolled this out, the larger ClientHello broke roughly 0.05 percent of connections to origins advertising the post-quantum group directly: small, not zero, and concentrated in exactly the enterprise network gear that is hardest to update.

  • Roll out behind a measurement, not a maintenance window. You are looking for a fractional failure rate, which a smoke test will not surface.
  • Suspect the path, not the endpoints. If it fails from one network and works from another, that is the signature.
  • Check anything that parses a handshake: inspecting proxies, older load balancers, some WAFs, and SNI-based TCP routers, which are a common casualty because they route on a field they now have to reassemble to find.
  • Do not respond by disabling the hybrid group estate-wide. Find the device. Turning it off returns you to recording traffic that stays decryptable forever.

What this does not fix

Enabling hybrid key exchange on your edge protects the leg between the client and that edge, from the moment you enable it. Three things remain, and the first is the one people miss.

  • The origin leg. If a CDN terminates TLS, the connection from the CDN to your origin is a separate handshake with its own algorithms, and it is frequently still classical. This is why public adoption numbers look better than most estates actually are.
  • Signatures. The certificate chain is still classical almost everywhere. That matters less urgently: a forged signature needs a quantum computer at the time of the forgery, whereas a recorded key exchange does not. See ML-DSA vs SLH-DSA for the signature side.
  • Everything that is not TLS. Data at rest, backups, message brokers, SSH, code signing, and the encrypted secret already sitting in your git history. A source-code fix moves you forward from today and does nothing for what was already captured, which is the whole harvest-now-decrypt-later problem.

TLS is the surface everyone starts with because it is the easiest to change and the easiest to measure. It is rarely the surface carrying the most unrecoverable exposure. Once this is done, the next question is what else in your estate pins itself to pre-quantum cryptography, which is what a cryptographic inventory answers.

Frequently asked questions

Do I need to do anything if I am on OpenSSL 3.5 or Go 1.24?

Probably not, and that is the point of checking first. Both offer X25519MLKEM768 by default. The exception is an explicit configuration: in OpenSSL a groups line in your app or config file, and in Go a non-nil Config.CurvePreferences. Either one replaces the default and silently drops the hybrid group unless you named it yourself.

Should I remove the classical fallbacks from my group list?

No. Clients that do not offer ML-KEM must still be able to complete a handshake, so a list containing only the hybrid group locks them out. Order it hybrid first, then X25519 and P-256. There is also no security argument for removing them: a hybrid is secure if either half holds, so the classical fallback is not weakening the hybrid connections.

Why did my nginx config change nothing?

Almost always because nginx is linked against an OpenSSL older than 3.5, which ignores group names it does not recognise instead of erroring. The config looks right and the reload succeeds. Check `nginx -V` for the OpenSSL version it was compiled against rather than the version your package manager reports.

A small percentage of connections started failing. What is it?

Very likely a middlebox that cannot handle a ClientHello spanning more than one TCP segment. The hybrid key share adds roughly a kilobyte, which frequently crosses that boundary, and devices that inspect only the first segment drop the connection. It presents as a fractional failure rate that correlates with the client's network rather than with your server. Find the device rather than disabling the hybrid group.

Does this make my site quantum-safe?

It makes the key exchange on that leg resistant to a future quantum computer, which is the part that protects traffic recorded today. It does not cover the origin connection behind a CDN, the certificate signatures, or anything that is not TLS. 'Quantum safe' is the wrong claim to make; 'traffic recorded today stays unreadable' is the right one.

Take the next step

Related reading

References

Get started

Turn quantum risk into a credential.

Book a discovery call and get an indicative scope and pricing for your organisation.