skip to content
QUANTUM NEXUM

← vault

Hybrid key exchange.

A hybrid key exchange combines a classical algorithm (X25519 or ECDH) with a post-quantum KEM (ML-KEM) so that an attacker must break both components to recover the shared secret. If a future large-scale quantum computer breaks the classical component, the ML-KEM component still holds. If ML-KEM is ever broken classically — the classical component still holds.

This page covers the hybrid construction itself. For how hybrid groups appear inside a TLS 1.3 handshake, see post-quantum TLS.

Why hybrid now

NIST finalized ML-KEM (FIPS 203) in August 2024. Major browsers (Chrome 131+, Firefox 132+, Safari 26+) already deploy X25519+ML-KEM-768 by default. Cloudflare, AWS, and Google have enabled it on their TLS termination infrastructure. The IETF working group document draft-ietf-tls-ecdhe-mlkem standardizes the group IDs and combiner construction for TLS.

NSA's CNSA 2.0 framework requires CNSA-compliant algorithms across National Security Systems on a tiered timeline, with preference beginning in 2025 and exclusive use by 2033. CNSA 2.0 permits hybrid deployments during the transition period but does not specifically require or recommend hybrid for NSS — the goal is migration to pure PQC. NIST and the broader industry recommend hybrid as the safest migration path for general deployments where the classical component does not impose a compliance constraint.

The combiner

The hybrid shared secret is derived by concatenating the two component shared secrets and running them through HKDF. The concatenation order for X25519MLKEM768 per draft-ietf-tls-ecdhe-mlkem is ML-KEM share first:

# Hybrid KEM Encapsulation (sender)
def hybrid_encaps(classical_pub, pq_pub):
    classical_ct, classical_ss = x25519_encaps(classical_pub)  # 32 B each
    pq_ct, pq_ss       = mlkem768_encaps(pq_pub)               # ct=1088 B, ss=32 B

    combined_ct = classical_ct || pq_ct                        # 32 + 1088 = 1120 B

    # ML-KEM share first per draft-ietf-tls-ecdhe-mlkem
    ikm = pq_ss || classical_ss                                # 64 B
    combined_ss = hkdf_sha256(ikm, info=b"hybrid-kem")

    return combined_ct, combined_ss


# Hybrid KEM Decapsulation (receiver)
def hybrid_decaps(classical_priv, pq_priv, combined_ct):
    classical_ct = combined_ct[:32]
    pq_ct        = combined_ct[32:]

    classical_ss = x25519_decaps(classical_priv, classical_ct)
    pq_ss        = mlkem768_decaps(pq_priv, pq_ct)

    ikm = pq_ss || classical_ss
    combined_ss = hkdf_sha256(ikm, info=b"hybrid-kem")
    return combined_ss

The specific construction above is illustrative. TLS 1.3 uses the TLS key schedule (HKDF-Extract / HKDF-Expand) rather than a freestanding HKDF call; the share concatenation order is what matters and is fixed in the IETF draft.

Common hybrid combinations

Combination Client key share Server response (ciphertext) Use case
X25519 + ML-KEM-768 32 + 1,184 = 1,216 B 32 + 1,088 = 1,120 B General purpose; browser default
P-256 + ML-KEM-768 65 + 1,184 = 1,249 B 65 + 1,088 = 1,153 B Environments requiring FIPS-approved curve for classical component
P-384 + ML-KEM-1024 97 + 1,568 = 1,665 B 97 + 1,568 = 1,665 B High-security; CNSA 2.0 preferred classical + Level 5 PQ
X25519 + ML-KEM-512 32 + 800 = 832 B 32 + 768 = 800 B IoT / bandwidth-constrained; NIST Level 1
X25519 + ML-KEM-1024 32 + 1,568 = 1,600 B 32 + 1,568 = 1,600 B Long-term secrets; Level 5 PQ with faster classical

Key share size comparison

Algorithm                     Share size (client)    Notes
X25519 only (classical)            32 B              Vulnerable to quantum attack
X25519 + ML-KEM-512 (hybrid)      832 B              Level 1 PQ
X25519 + ML-KEM-768 (hybrid)    1,216 B              Level 3 PQ; browser default
P-256 + ML-KEM-768 (hybrid)     1,249 B              FIPS-approved curve
P-384 + ML-KEM-1024 (hybrid)    1,665 B              Level 5 PQ; CNSA 2.0

The increase from 32 B to ~1.2 KB adds roughly one extra TCP segment to the initial handshake — negligible latency for most workloads. Path MTU issues or fragmentation-sensitive middleboxes may need configuration adjustments; see the pq-tls page for diagnosing large key share blocking.

ML-KEM parameter sets

Parameter set NIST Level Public key Ciphertext Shared secret
ML-KEM-512 1 (AES-128 equivalent) 800 B 768 B 32 B
ML-KEM-768 3 (AES-192 equivalent) 1,184 B 1,088 B 32 B
ML-KEM-1024 5 (AES-256 equivalent) 1,568 B 1,568 B 32 B

All three are standardized in FIPS 203. The shared secret is always 32 bytes regardless of parameter set.

Local testing with OpenSSL

OpenSSL 3.5+ supports hybrid groups natively. For older OpenSSL 3.x, install the OQS provider (latest releases: liboqs 0.15.x / oqs-provider 0.11.x as of early 2026).

OpenSSL loopback test

# Generate a certificate for the loopback server
openssl req -x509 -newkey ML-DSA-65 -keyout key.pem -out cert.pem \
    -days 365 -nodes -subj "/CN=localhost"

# Start local server advertising hybrid groups
openssl s_server -accept 14433 -cert cert.pem -key key.pem \
    -groups X25519MLKEM768 -www -tls1_3

# In a second terminal: connect and inspect the key share length
openssl s_client -connect localhost:14433 \
    -groups X25519MLKEM768 \
    -tlsextdebug </dev/null 2>&1 | grep -E "key share|Negotiated"

key share (id=51), len=1124 confirms X25519MLKEM768 was negotiated (1120 B payload + 4 B header). len=32 means classical-only fallback.

OQS provider (OpenSSL 3.x)

OPENSSL_MODULES="/usr/lib64/ossl-modules" openssl s_client \
    -provider oqsprovider -provider default \
    -connect localhost:14433 \
    -groups X25519MLKEM768 \
    -tlsextdebug </dev/null 2>&1 | grep -E "key share|Cipher is"

Server configuration

# Nginx (OpenSSL 3.5+)
ssl_protocols TLSv1.3;
ssl_ecdh_curve X25519MLKEM768:X25519:P-256;

# Apache
SSLProtocol TLSv1.3
SSLOpenSSLConfCmd Groups X25519MLKEM768:X25519

# HAProxy
ssl-default-bind-ciphersuites TLS_AES_256_GCM_SHA384
ssl-default-bind-curves X25519MLKEM768:X25519

# Caddy 2.9+ (Go 1.24 crypto/tls)
tls {
    curves x25519mlkem768 x25519
}

Library support

Library / runtime ML-KEM hybrid Notes
OpenSSL 3.5+ Native ML-KEM in default provider; no OQS provider needed
OpenSSL 3.x + OQS provider Via provider Latest: oqs-provider 0.11.x
BoringSSL (Chrome / Android) Yes X25519MLKEM768 enabled in Chrome 131+
NSS (Firefox) Yes Firefox 132+
Go crypto/tls Yes (Go 1.24+) Used by Caddy 2.9+
Cloudflare CIRCL (Go) Yes github.com/cloudflare/circl/kem/mlkem
liboqs (C, Python, Java, .NET) Yes Reference implementations; latest 0.15.x
AWS s2n-tls Yes Enabled by default on AWS TLS termination
Rustls In progress Tracking upstream

Hybrid vs. pure PQC

Hybrid schemes are the recommended migration path during the transition period, while the industry builds confidence in the new algorithms and broad interoperability is established. Pure ML-KEM (without a classical component) is technically valid under FIPS 203 but is not yet common in TLS deployments.

CNSA 2.0's end goal is exclusive use of CNSA-approved algorithms (including ML-KEM-1024 and ML-DSA-87) with the classical component removed. For general commercial deployments, NIST does not mandate hybrid — but recommends it as defense in depth during the transition.

iMessage PQ3

Apple's iMessage PQ3 (announced 2024) uses a post-quantum component in its messaging ratchet. The initial deployment used pre-standard Kyber — not final ML-KEM. This is a different application context from TLS key exchange; the two are not directly comparable.

Obsolete draft codepoints

Before FIPS 203 was finalized, some implementations shipped provisional hybrid codepoints for pre-standard Kyber (x25519_kyber768, codepoint 0x6399). That group is not interoperable with X25519MLKEM768 (0x11EC): different codepoint, pre-standard KEM, and reversed share order. If your TLS logs show id=0x6399, the endpoint is running obsolete software.

Further reading