Post-quantum and TLS

Checking a server's TLS key exchange, the secure way

The vendor slide says "quantum-safe". The browser padlock says nothing. You want one line of terminal output that settles it, and you want to be sure that line means what you think it means.

The short answer

Use a client built on OpenSSL 3.5 or newer. Run openssl s_client once with its default groups and once with -groups MLKEM1024. The line "Negotiated TLS1.3 group" names a post-quantum or hybrid group; "Peer Temp Key: X25519" means classical. curl on OpenSSL 3.5 prints the group in its "SSL connection using" line.

Updated Houssam Hammoudi, CTOTested with OpenSSL 3.5.8, curl 8.14.1 (alpine:3.22)

On this page
  1. What goes wrong
  2. What the docs say
  3. The secure configuration
  4. Prove it
  5. Mistakes people make
  6. Checklist

What goes wrong

A TLS 1.3 handshake agrees on one key exchange group. That group decides whether recorded traffic can be decrypted later by a quantum computer. X25519 is classical. X25519MLKEM768 is a hybrid of X25519 and ML-KEM-768 (NIST level 3). MLKEM1024 is ML-KEM-1024 on its own (NIST level 5).

The server can only pick a group that the client offers. So the answer you get depends on the client you test with. An old OpenSSL (3.4 or earlier) does not know ML-KEM at all, so every server looks classical to it. A test from that machine proves nothing.

The second trap is the output. OpenSSL 3.5 prints a different line for a classical group than for a post-quantum one. People grep for "Negotiated", get nothing, and assume the command failed, or that the server is broken.

The third trap is asking the wrong question. "Does it do post-quantum?" and "Does it do NIST level 5?" have different answers on most of the web today. Browsers offer only the level 3 hybrid, so a server can be post-quantum for browsers and still refuse MLKEM1024.

What the docs say

The DEFAULT list selects X25519MLKEM768 as one of the predicted keyshares.

Source: OpenSSL 3.5 docs, SSL_CTX_set1_groups_list

A server choosing a group outside the client's predicted subset incurs an extra roundtrip.

Source: OpenSSL 3.5 docs, SSL_CTX_set1_groups_list

Set specific curves to use during SSL session establishment according to RFC 8422, 5.1.

Source: curl man page, --curves

The OpenSSL s_client manual page lists -groups but does not describe the summary lines it prints, so nothing tells you that a classical result shows up as "Peer Temp Key" and a post-quantum one as "Negotiated TLS1.3 group".

The secure configuration

There is nothing to configure on the server for this page. The configuration is the client. Check the version first, then run the three checks.

bash
# 1. The client must be OpenSSL 3.5 or newer, or the test is meaningless.
openssl version
# OpenSSL 3.5.x ...  -> knows ML-KEM
# OpenSSL 3.4.x or older -> every server will look classical

# 2. What a modern client gets: OpenSSL's default groups offer
#    X25519MLKEM768 first, like current browsers.
openssl s_client -connect example.com:443 -servername example.com </dev/null 2>/dev/null \
  | grep -E "^Negotiated TLS1.3 group|^Peer Temp Key"

# 3. Level 5: offer MLKEM1024 only. A handshake failure means "not supported".
openssl s_client -connect example.com:443 -servername example.com \
  -groups MLKEM1024 </dev/null 2>&1 | grep -E "^Negotiated TLS1.3 group|alert"

# 4. The server's preference: offer all three, strongest first.
openssl s_client -connect example.com:443 -servername example.com \
  -groups MLKEM1024:X25519MLKEM768:X25519 </dev/null 2>/dev/null \
  | grep -E "^Negotiated TLS1.3 group|^Peer Temp Key"

# 5. The same from curl (curl must be linked against OpenSSL 3.5+).
curl -sv -o /dev/null https://example.com/ 2>&1 | grep "SSL connection using"
curl -sv --curves MLKEM1024 -o /dev/null https://example.com/ 2>&1 | grep "SSL connection using"

How to read the result:

Output lineMeaning
Negotiated TLS1.3 group: MLKEM1024ML-KEM-1024, NIST level 5
Negotiated TLS1.3 group: X25519MLKEM768Hybrid, NIST level 3, what browsers get
Peer Temp Key: X25519, 253 bitsClassical. Recorded traffic is exposed to a future quantum computer
alert handshake failure with -groups MLKEM1024The server does not offer ML-KEM-1024

Prove it

Run on 2026-09-24 from alpine:3.22 with the script in secure-tests/check-tls-key-exchange/run.sh. Public hosts change their settings, so your results may differ from these.

bash
openssl s_client -connect cloudflare.com:443 -servername cloudflare.com </dev/null 2>/dev/null | grep -E "^Negotiated TLS1.3 group|^Peer Temp Key"
text
Negotiated TLS1.3 group: X25519MLKEM768

A classical answer uses a different line:

bash
openssl s_client -connect github.com:443 -servername github.com </dev/null 2>/dev/null | grep -E "^Negotiated TLS1.3 group|^Peer Temp Key"
text
Peer Temp Key: X25519, 253 bits

Level 5 on its own: one server accepts it, the other refuses.

bash
openssl s_client -connect www.google.com:443 -servername www.google.com -groups MLKEM1024 </dev/null 2>&1 | grep -E "^Negotiated TLS1.3 group|alert"
text
Negotiated TLS1.3 group: MLKEM1024
bash
openssl s_client -connect cloudflare.com:443 -servername cloudflare.com -groups MLKEM1024 </dev/null 2>&1 | grep -E "^Negotiated TLS1.3 group|alert"
text
error:0A000410:SSL routines:ssl3_read_bytes:ssl/tls alert handshake failure:ssl/record/rec_layer_s3.c:918:SSL alert number 40
Negotiated TLS1.3 group: <NULL>

Offer all three and the server shows its preference:

bash
openssl s_client -connect www.google.com:443 -servername www.google.com -groups MLKEM1024:X25519MLKEM768:X25519 </dev/null 2>/dev/null | grep -E "^Negotiated TLS1.3 group|^Peer Temp Key"
text
Negotiated TLS1.3 group: MLKEM1024

curl prints the group as the third field of its connection line:

bash
curl -sv -o /dev/null https://cloudflare.com/ 2>&1 | grep "SSL connection using"
curl -sv --curves MLKEM1024 -o /dev/null https://www.google.com/ 2>&1 | grep "SSL connection using"
text
* SSL connection using TLSv1.3 / TLS_AES_256_GCM_SHA384 / X25519MLKEM768 / id-ecPublicKey
* SSL connection using TLSv1.3 / TLS_AES_256_GCM_SHA384 / MLKEM1024 / id-ecPublicKey

Mistakes people make

Testing from a laptop that cannot speak ML-KEM

OpenSSL before 3.5 and many system curl builds do not offer any ML-KEM group. The server then correctly picks X25519, and the test reports "classical" for a server that is fine. Check openssl version and curl --version first, or run the test from a container such as alpine:3.22.

Grepping for the wrong line

OpenSSL 3.5 prints Negotiated TLS1.3 group: for groups it reports by name and Peer Temp Key: for a classical ECDH key. Grep for both. An empty result is not a pass.

Treating "post-quantum" and "level 5" as the same question

The default client offer only proves the server accepts the level 3 hybrid. To check ML-KEM-1024, offer -groups MLKEM1024 on its own. If you need CNSA 2.0, that is the only result that counts.

Reading a handshake failure as a network problem

With -groups MLKEM1024, a refusal comes back as alert handshake failure (alert 40). That is the server saying it does not support the group, not a firewall or DNS issue.

Checking only the front door

The public listener is one hop. The load balancer to backend hop, the mesh, the database connection and SSH carry the same data. Check each of them, or use an inventory script such as the one on the estate page.

Checklist

  • Run openssl version and confirm 3.5 or newer before any test.
  • Check the default offer and record the negotiated group.
  • Check -groups MLKEM1024 on its own and record accept or refuse.
  • Check -groups MLKEM1024:X25519MLKEM768:X25519 to see the server's preference.
  • Grep for both Negotiated TLS1.3 group and Peer Temp Key.
  • Repeat the check for every internal TLS hop, not only the public hostname.
  • Date every result; servers change.

One grep, two possible lines, and now you know which one you are looking at.

FND

Learn it on a live range

Networking and protocols, in Foundation: a real host in your browser, and every objective checked on the machine.

Start free

The Dome

Want it run for you?

The Dome puts post-quantum TLS, a WAF that blocks, signed DNS and a zero-trust mesh in front of your application. Tell us what you run.

See the Dome