Hybrid Key Exchange X25519 + ML-KEM-768: The Complete Guide
Hybrid post-quantum key exchange combines classical X25519 with ML-KEM-768 so a session stays secret if either primitive holds. This is the exact construction, failure modes, and code to implement it correctly.
TL;DR
A hybrid post-quantum key exchange combines two key-agreement primitives — one classical, one post-quantum — so the resulting session key is secure if either primitive alone is secure. The production default in 2026 is X25519 paired with ML-KEM-768. The correct combiner is to run both key exchanges in parallel, concatenate the two shared secrets, fold the handshake transcript into a salt, and derive the session key through HKDF. This post spells out the construction, the gotchas, and a minimal Go implementation you can read in full. We use this construction in every QuickZTNA tunnel. Every major standards body — NIST, IETF, NSA, BSI, ANSSI — recommends hybrid for the transition window.
Who this is for
Protocol designers, library authors, and platform engineers who have to implement post-quantum key exchange correctly. Also security architects who want enough technical detail to review a vendor’s design. This post assumes familiarity with Diffie-Hellman and with the concept of a key encapsulation mechanism. If you want the concept without the implementation, start with ML-KEM-768 Explained first.
Table of contents
- Why hybrid at all
- The four requirements
- The construction, step by step
- Transcript binding in detail
- Minimal Go implementation
- Common mistakes and how to avoid them
- How this lands in TLS 1.3
- How this lands in WireGuard and QuickZTNA
- Testing and interoperability
- Standards and references
1. Why hybrid at all
A hybrid construction sidesteps two risks at once.
Risk 1: ML-KEM turns out to be weaker than we think. ML-KEM is young as a deployed standard. The underlying Module-LWE problem has been studied for about a decade and no fundamental break has been found, but the history of cryptography includes enough examples of “safe” primitives falling later that a pure-PQ deployment is premature. Past examples: SHA-1 (deprecated after 20 years), MD5 (deprecated after 12), the original NIST P-curves (still standard but now with known subtle engineering concerns).
Risk 2: X25519 falls to a quantum adversary, on the timeline anyone picks. This is the entire motivation for post-quantum. Shor’s algorithm breaks elliptic-curve discrete log on a sufficiently large fault-tolerant quantum computer. Nobody has such a computer today. Someone might in 2033 or 2040.
A hybrid construction is secure against both risks simultaneously. If ML-KEM is broken by an unknown lattice attack, X25519 still protects the session against classical adversaries. If X25519 falls to a quantum adversary, ML-KEM still protects the session against that adversary. The only way both defences fail at once is a simultaneous break of both, which is vanishingly unlikely.
Every major standards body — NIST SP 800-56C, the IETF hybrid key exchange draft for TLS, the NSA CNSA 2.0 guidance, the German BSI, the French ANSSI — recommends hybrid deployments for the transition window. Deploy pure post-quantum only when cryptanalysis is a decade older than it is today.
2. The four requirements
A correct hybrid combiner meets four properties. Borrowed from the Bindel, Brendel, Fischlin, Goncalves, and Stebila analysis (ePrint 2018/903).
- Security if either primitive is secure. The combined secret must be indistinguishable from random as long as at least one of the underlying secrets is.
- Transcript binding. The final key must depend on the full handshake, so a man-in-the-middle cannot rewrite other fields and still arrive at the same key.
- Key confirmation at the right level. Whoever is meant to confirm possession of the key must do so; this typically happens in the protocol layer above the key exchange.
- Explicit domain separation. The output of the combiner must not be confusable with the output of any other key-derivation step in the protocol.
These four requirements rule out some appealing-but-broken constructions. Single-hash combiners miss the transcript-binding requirement unless the transcript is explicit. Simple XOR combiners can fail the “security if either is secure” requirement under certain failure modes. Construct it the way the standards say, not the way that looks short.
3. The construction, step by step
In words and in pseudocode.
3.1 Generate ephemeral key pairs on both sides
# Initiator
(i_classical_pk, i_classical_sk) = X25519_KeyGen()
(i_pq_pk, i_pq_sk) = ML_KEM_768_KeyGen()
# Responder
(r_classical_pk, r_classical_sk) = X25519_KeyGen()
(r_pq_pk, r_pq_sk) = ML_KEM_768_KeyGen()
Both keys must be fresh per handshake. Do not reuse across rekeys. Do not reuse across peers.
3.2 Exchange the public halves
The initiator sends i_classical_pk and i_pq_pk. The responder sends r_classical_pk and the ML-KEM ciphertext produced by encapsulating against i_pq_pk. In a request/response style protocol the responder’s message is:
# Responder
classical_shared = X25519(r_classical_sk, i_classical_pk)
(ciphertext, pq_shared) = ML_KEM_768_Encap(i_pq_pk)
# Send back: r_classical_pk, ciphertext
3.3 Initiator recomputes both shared secrets
# Initiator
classical_shared = X25519(i_classical_sk, r_classical_pk)
pq_shared = ML_KEM_768_Decap(i_pq_sk, ciphertext)
At this point both sides hold classical_shared (32 bytes) and pq_shared (32 bytes). These are the two raw agreement outputs.
3.4 Combine with a KDF, binding the transcript
The concatenate-then-KDF construction.
combined = classical_shared || pq_shared # 64 bytes total
salt = SHA256(transcript) # handshake messages so far
key_material = HKDF(
IKM = combined,
salt = salt,
info = "quickztna-pqc-wg-psk-v1",
len = 32
)
Some design choices worth flagging:
- Concatenation order matters for determinism. Both sides must agree on the order (classical first, then post-quantum). We fix it in the protocol spec.
- Each side computes
combinedindependently. The order is deterministic from the fixed spec, not from any random nonce. saltis the transcript hash. Not a counter, not a nonce, not a fixed value. This is what binds the combined key to the specific handshake messages.infois a fixed ASCII string. This provides domain separation from any other KDF step in the protocol. We version it (-v1) so future revisions are explicit.- Output length is the symmetric key length you need. For WireGuard’s pre-shared key field, 32 bytes is enough.
3.5 Zero out the temporaries
wipe(classical_shared)
wipe(pq_shared)
wipe(combined)
Both shared secrets must be overwritten before the buffers go out of scope, to reduce memory-scraping exposure. In Go, use subtle.ConstantTimeCopy to zero; in Rust, use zeroize crate; in C, use OPENSSL_cleanse or equivalent.
4. Transcript binding in detail
The transcript is not an optional decoration. It is the answer to a specific attack.
Consider a man-in-the-middle who can observe both halves of the handshake but cannot modify the ML-KEM or X25519 contributions directly (because doing so would invalidate the KEM math or the Diffie-Hellman). Without transcript binding, the attacker can still rewrite auxiliary fields — protocol version, supported groups, identity hints — and the two sides will derive the same session key despite disagreeing on the rewritten fields. That is a split-view attack.
Binding the transcript fixes this. Any rewrite of auxiliary fields changes the transcript, which changes the salt, which changes the derived session key. The two sides end up with different keys and the next message fails AEAD authentication. The attacker has nothing useful.
The specific hash we use is SHA-256 of the concatenated handshake messages, in the order they were sent, with unambiguous length prefixes. Unambiguous length prefixes matter: if the transcript is just a plain concatenation, an attacker can sometimes move bytes between fields without changing the concatenation.
5. Minimal Go implementation
A minimal, readable implementation using the Go 1.24 standard library. Not production code (no error handling, no zeroisation, no audit logs), but correct in its construction.
package hybrid
import (
"crypto/hkdf"
"crypto/mlkem"
"crypto/sha256"
"crypto/subtle"
"golang.org/x/crypto/curve25519"
)
const info = "quickztna-pqc-wg-psk-v1"
type InitiatorState struct {
X25519Priv [32]byte
MLKEMPriv *mlkem.DecapsulationKey768
}
type InitiatorMessage struct {
X25519Pub [32]byte
MLKEMPub []byte // 1184 bytes
}
type ResponderMessage struct {
X25519Pub [32]byte
MLKEMCipher []byte // 1088 bytes
}
// Initiator: produce the first handshake message and retain state.
func Initiate() (InitiatorState, InitiatorMessage, error) {
var msg InitiatorMessage
var st InitiatorState
// Classical half.
if _, err := readRandom(st.X25519Priv[:]); err != nil {
return st, msg, err
}
curve25519.ScalarBaseMult(&msg.X25519Pub, &st.X25519Priv)
// Post-quantum half.
pq, err := mlkem.GenerateKey768()
if err != nil {
return st, msg, err
}
st.MLKEMPriv = pq
msg.MLKEMPub = pq.EncapsulationKey().Bytes()
return st, msg, nil
}
// Responder: consume the initiator's message, produce the response, derive key.
func Respond(im InitiatorMessage, transcript []byte) (ResponderMessage, []byte, error) {
var rm ResponderMessage
// Classical half.
var rPriv [32]byte
if _, err := readRandom(rPriv[:]); err != nil {
return rm, nil, err
}
curve25519.ScalarBaseMult(&rm.X25519Pub, &rPriv)
classicalShared, err := curve25519.X25519(rPriv[:], im.X25519Pub[:])
if err != nil {
return rm, nil, err
}
// Post-quantum half.
ek, err := mlkem.NewEncapsulationKey768(im.MLKEMPub)
if err != nil {
return rm, nil, err
}
pqShared, ciphertext := ek.Encapsulate()
rm.MLKEMCipher = ciphertext
// Combine.
combined := append([]byte{}, classicalShared...)
combined = append(combined, pqShared...)
salt := sha256.Sum256(transcript)
key, err := hkdf.Key(sha256.New, combined, salt[:], info, 32)
if err != nil {
return rm, nil, err
}
subtle.ConstantTimeCopy(1, classicalShared, make([]byte, len(classicalShared)))
subtle.ConstantTimeCopy(1, pqShared, make([]byte, len(pqShared)))
subtle.ConstantTimeCopy(1, combined, make([]byte, len(combined)))
return rm, key, nil
}
// Initiator: consume the responder's message, derive the same key.
func Finalise(st InitiatorState, rm ResponderMessage, transcript []byte) ([]byte, error) {
classicalShared, err := curve25519.X25519(st.X25519Priv[:], rm.X25519Pub[:])
if err != nil {
return nil, err
}
pqShared, err := st.MLKEMPriv.Decapsulate(rm.MLKEMCipher)
if err != nil {
return nil, err
}
combined := append([]byte{}, classicalShared...)
combined = append(combined, pqShared...)
salt := sha256.Sum256(transcript)
return hkdf.Key(sha256.New, combined, salt[:], info, 32)
}
The actual QuickZTNA implementation is more careful about zeroisation, uses structured error types, serialises the transcript with explicit length prefixes, and handles negotiation of PQ support. But this is the shape.
6. Common mistakes and how to avoid them
Ten specific mistakes we have seen in reviewed implementations — ours and others’.
6.1 Hashing inputs before handing them to HKDF
HKDF already does the extraction step for you. Hashing inputs first is redundant and sometimes reduces security because the pre-hash loses entropy information. Pass raw combined to HKDF and let it do its job.
6.2 Using the same info string for different derivations
Every distinct KDF output in your protocol must have a distinct info string. If you derive a traffic key and a header-protection key from the same IKM, use two different info strings. Otherwise an attacker can cross-reference derivations.
6.3 Mixing a salt that is not public
The HKDF salt can be public — that is fine by construction. What must be true is that the salt is agreed on by both sides. Using a secret salt does not add security and creates key-agreement problems. Use the handshake transcript hash, which is public and agreed.
6.4 Reusing the ML-KEM key pair across handshakes
Forward secrecy requires ephemeral keys. Reusing the ML-KEM public key across handshakes means a compromise of the one long-lived secret key breaks every past session protected by it. Rotate per handshake.
6.5 Skipping validation on deserialised public keys
FIPS 203 specifies that implementations must validate incoming public keys to be sure they are in the valid range. A malformed public key can be used as an oracle to leak information about your secret. Use a library that validates, or do it yourself with the exact checks the standard names.
6.6 Treating the ciphertext as opaque bytes when it is not
ML-KEM ciphertexts have structure. Some libraries accept any 1,088-byte input; others validate. A non-validating peer feeding malformed ciphertexts to your peer is not the attack you want to permit. Prefer the stricter library.
6.7 Assuming Go’s crypto/mlkem is a drop-in replacement for Kyber libraries
It is not. Pre-standard Kyber libraries produced different outputs. Any system that serialised Kyber public keys or ciphertexts to storage or the wire and expects to interoperate with ML-KEM will fail.
6.8 Not logging the mode
If your deployment is a mixed world of classical-only and hybrid peers, you need to know which sessions were which for audit. Log the mode on every established session. Include it in your incident-response tooling.
6.9 Hard-coding the algorithm
Your abstraction should have an algorithm identifier in the protocol that names the specific hybrid construction. Future upgrades to ML-KEM-1024 or to a different hybrid combiner should be a config change, not a protocol fork.
6.10 Silent fallback
If a peer does not support PQ, a silent drop to classical-only is the worst behaviour because it masks the regression. Either fall back with a loud audit event, or fail closed. QuickZTNA defaults to the former; strict compliance policies can switch to the latter.
7. How this lands in TLS 1.3
The IETF hybrid key exchange draft (draft-ietf-tls-hybrid-design) specifies a TLS 1.3 extension in which the client advertises hybrid groups and the server selects one. The 2025 consensus code-point for X25519 + ML-KEM-768 is 0x11EC (named X25519MLKEM768).
Implementation status in 2026:
- Chrome and Chromium: Hybrid X25519 + ML-KEM-768 enabled by default, originally rolled out as X25519Kyber768 in 2023, migrated to ML-KEM naming post-FIPS 203. Status visible at
chrome://flags/#post-quantum-key-agreement-for-tls. - Firefox: Support exists, enabled by default for connections to hybrid-capable servers.
- Edge: Inherits Chromium support.
- Safari: Rollout status varies by macOS version; check Apple release notes for the specific version you are testing.
- Cloudflare: Hybrid enabled by default on edge endpoints as of 2023 (X25519Kyber768), migrated to ML-KEM-768 hybrid post-FIPS 203.
- Google Cloud edges: Partial rollout, varies by service.
- AWS KMS and several AWS TLS endpoints: documented post-quantum rollout; check per-service.
You can check your own TLS endpoint with:
openssl s_client -curves X25519MLKEM768 -connect example.com:443 < /dev/null
# Look for: Server Temp Key: ... X25519MLKEM768 ...
If you see the hybrid group in the server temp key line, the endpoint supports hybrid. If not, it falls back to classical.
8. How this lands in WireGuard and QuickZTNA
WireGuard does not have a TLS-style codepoint or negotiation. The protocol is fixed. What it does have is an optional pre-shared key (PSK) field on every peer, mixed into the handshake for additional forward secrecy.
QuickZTNA runs the hybrid X25519 + ML-KEM-768 exchange as a separate protocol at a higher layer, relayed through our coordination server. The resulting 32-byte derived key is installed as the WireGuard PSK for that peer. WireGuard’s own Noise handshake then runs as normal, but with the PSK field populated from a post-quantum exchange rather than left empty or statically configured.
A few consequences worth highlighting:
- Classical-capable WireGuard kernel modules continue to work. If one side does not support QuickZTNA’s PQ layer, the tunnel establishes classical-only. This is visible in the dashboard.
- The PSK rotates on every WireGuard rekey. WireGuard rekeys every 120 seconds by default. QuickZTNA re-runs the hybrid derivation on that cadence.
- Handshake cost is amortised. On the first establishment, you pay for ephemeral key generation on both sides plus the relay round-trip. On subsequent rekeys, only the ML-KEM encap/decap operations are re-run; classical Curve25519 is already in a fast path.
- Audit log carries the mode. Every established and renewed session records
kex=hybrid-x25519-mlkem768orkex=classical-onlyalong with the peer identity and timestamp.
Our detailed walkthrough of the implementation is in the ML-KEM-768 explained post and the post-quantum section of the docs.
9. Testing and interoperability
Five tests you should run before you trust your own hybrid implementation.
9.1 Known-answer tests from the standards
Use the NIST-published ML-KEM test vectors and an X25519 reference vector to confirm your primitives agree with the standard. A run-over failure here is almost always a polynomial arithmetic bug.
9.2 Cross-implementation interop
Build the opposite side against a different library. If your Go implementation agrees on a derived key with an OpenSSL 3.5 peer using the same inputs, your construction is right. If they disagree, somebody is wrong and a side-channel investigation is warranted.
9.3 Transcript tampering
Flip one bit in a deliberate transcript field on one side. The derived keys must disagree. If they agree, your transcript binding is broken.
9.4 Ciphertext tampering
Flip one bit in the ML-KEM ciphertext on the wire. The initiator must either reject decapsulation or derive a different key. Either is acceptable; silent agreement on the same key is a bug.
9.5 Long-running fuzz
Run a day-long fuzz of the deserialisers with AFL or libFuzzer. Crashes are bugs. Hangs are bugs. Silent failures that return a zero-length ciphertext are bugs.
10. Standards and references
All verified on publication date.
- NIST SP 800-56C Rev. 2 — Recommendation for Key-Derivation Methods in Key-Establishment Schemes. The authoritative reference for combiner constructions.
- FIPS 203 — Module-Lattice-Based Key-Encapsulation Mechanism Standard.
- IETF draft-ietf-tls-hybrid-design — Hybrid key exchange in TLS 1.3.
- RFC 5869 — HMAC-based Extract-and-Expand Key Derivation Function (HKDF).
- Bindel, Brendel, Fischlin, Goncalves, Stebila — Hybrid Key Encapsulation Mechanisms (2018).
- NSA CNSA 2.0.
- Go
crypto/mlkemdocumentation.
Related reading on this blog
- ML-KEM-768 Explained: The Quantum-Safe Algorithm in Every QuickZTNA Tunnel
- Harvest Now, Decrypt Later: Why Your VPN Traffic Is Already Compromised
- Post-Quantum VPN: 6 Questions to Ask Your Current Vendor
Try QuickZTNA
Every QuickZTNA tunnel uses the exact hybrid X25519 + ML-KEM-768 construction described above, with transcript binding through HKDF-SHA256 and a two-minute rekey interval. Test it on the Free tier: sign up, connect two devices, and run ztna status -v to see the kex mode reported for each tunnel.
Frequently asked questions
Why concatenate the two shared secrets instead of XOR-ing them?
XOR combiners can leak structure if either input has biased bits or if an attacker gains partial information about one of the shared secrets. Concatenation followed by a KDF preserves all entropy from both inputs and is the construction recommended in NIST SP 800-56C and the IETF hybrid key exchange draft. Do not invent a custom combiner.
Is hybrid slower than classical?
Only marginally. On commodity hardware, the ML-KEM-768 operations add roughly 250 microseconds of CPU per handshake on top of X25519's 7 microseconds. The larger hit is the 2,272 extra bytes of handshake traffic, which adds roughly 100 microseconds on a 100 Mbit link. Total hybrid handshake overhead in production QuickZTNA deployments is under 5 milliseconds.
Can I use a hybrid key exchange for signatures too?
Yes. The signature analogue is a hybrid signature: sign with a classical signature algorithm and a post-quantum signature algorithm in parallel and verify both. But signatures and key exchange are separate problems and the migrations run on different timelines. This post covers key exchange only.
What happens if one peer speaks hybrid and the other does not?
Either the handshake falls back to classical-only or it fails, depending on policy. In QuickZTNA the default is graceful fallback with an audit log entry. In strict compliance deployments you can set a policy that refuses classical-only tunnels entirely, at the cost of interoperability with stock WireGuard peers.
Does hybrid preserve forward secrecy?
Yes, provided both key pairs are ephemeral per handshake. Forward secrecy is a property of the protocol using the key exchange, not of the key exchange primitives themselves. The QuickZTNA construction re-runs the full hybrid derivation on every WireGuard rekey (every two minutes by default), so each session key is derived from fresh ephemeral material on both legs.
Is hybrid TLS 1.3 already in common browsers?
Yes. Chrome, Edge, and Firefox have all shipped hybrid post-quantum TLS 1.3 key exchange by default for connections to servers that advertise it, using the X25519 + ML-KEM-768 group (TLS codepoint 0x11EC). As of 2026, Cloudflare, Google, and several major CDNs also advertise it on their edges. Chromium's rollout timeline is documented on the Chromium blog and the status can be inspected at chrome://flags for debugging.