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
- draft-ietf-tls-ecdhe-mlkem — group IDs, combiner construction
- FIPS 203 — ML-KEM specification
- NSA CNSA 2.0 FAQ — transition timelines
- post-quantum TLS — how hybrid groups fit into the TLS handshake
- algorithms — ML-KEM and ML-DSA parameter sets
- compatibility — platform support matrix