<!-- Canonical: https://lucioduran.com/blog/tls-1-3-post-quantum-kyber-hybrid
Language: en
Content-SHA256: 8657ff0a926b059d3e866060425c98c8877a5d57f1c1a21c9c415a3385192593
-->

Security

# TLS 1.3 + Post-Quantum: ML-KEM (Kyber) Hybrid Key Exchange in the Real World

2026-03-108 min read

[Lucio Durán](<https://lucioduran.com/>)
Engineering Manager & AI Solutions Architect

Also available in: [Español](<https://lucioduran.com/es/blog/tls-1-3-post-quantum-kyber-hybrid>)

## The Threat Model: Why We Can't Wait

The argument for post-quantum cryptography isn't speculative anymore. It's straightforward risk math:

1. Encrypted data transits the internet
2. Adversaries record that encrypted traffic (cheap storage makes this trivial)
3. A cryptographically relevant quantum computer (CRQC) eventually appears
4. All recorded traffic encrypted with classical key exchange becomes readable

This is the "harvest now, decrypt later" (HNDL) attack. The data you're protecting with TLS today might need to remain confidential for 10, 20, or 30 years. If a CRQC appears within that window, your classical key exchange was for nothing.

The urgency calculation: if your data needs N years of confidentiality, and you estimate T years until a CRQC, and it takes M years to migrate your infrastructure, you need to start when N + M \> T. For most organizations with sensitive data, that inequality already holds.

## ML-KEM: What NIST Actually Standardized

[FIPS 203](<https://csrc.nist.gov/pubs/fips/203/final>), published August 2024, standardized ML-KEM (Module-Lattice-Based Key-Encapsulation Mechanism) in three parameter sets:

| Parameter Set | Security Level | Public Key Size | Ciphertext Size | Shared Secret |
| --- | --- | --- | --- | --- |
| ML-KEM-512 | NIST Level 1 | 800 bytes | 768 bytes | 32 bytes |
| ML-KEM-768 | NIST Level 3 | 1,184 bytes | 1,088 bytes | 32 bytes |
| ML-KEM-1024 | NIST Level 5 | 1,568 bytes | 1,568 bytes | 32 bytes |

ML-KEM-768 is the sweet spot that everyone's deploying. Level 3 security (roughly equivalent to AES-192), reasonable key sizes, and it's what both Chrome and Firefox chose for their hybrid implementation.

The core math is based on the hardness of the Module Learning With Errors (MLWE) problem on lattices. Without going down the number theory rabbit hole, the fundamental operation is multiplying polynomials in a ring and adding structured noise. The security relies on the assumption that recovering the secret from noisy ring-LWE samples is hard for both classical and quantum computers.

## The Hybrid Approach: X25519Kyber768

Nobody is deploying ML-KEM alone. The entire industry has converged on a hybrid approach where you combine a classical key exchange with a post-quantum one. The shared secret is derived from both, so you maintain security even if either algorithm turns out to have an unexpected weakness.

Here's what the TLS 1.3 handshake looks like with ` X25519Kyber768Draft00 `:

```
Client Server

ClientHello
 + key_share: {
 X25519Kyber768Draft00: x25519_pub || mlkem768_encaps_key,
 X25519: x25519_pub_fallback
 }
 + supported_groups: [X25519Kyber768Draft00, X25519, ...]
 + signature_algorithms: [ecdsa_secp256r1_sha256, ...]
 -------->

 ServerHello
 + key_share: {
 X25519Kyber768Draft00:
 x25519_server_pub || mlkem768_ciphertext
 }
 {EncryptedExtensions}
 {Certificate}
 {CertificateVerify}
 {Finished}
 <--------

{Finished}
 -------->

[Application Data] <-------> [Application Data]
```

The combined shared secret computation:

```python
# Pseudocode for shared secret derivation
def derive_hybrid_secret(x25519_shared, mlkem_shared):
 # Concatenate both shared secrets
 combined = x25519_shared + mlkem_shared # 32 + 32 = 64 bytes

 # Feed into TLS 1.3 key schedule
 # The handshake secret is derived via HKDF-Extract
 early_secret = HKDF_Extract(salt=0, ikm=PSK or 0)
 handshake_secret = HKDF_Extract(
 salt=Derive_Secret(early_secret, "derived", ""),
 ikm=combined # <-- Both secrets contribute here
 )
 return handshake_secret
```

The critical property: if ML-KEM is broken by a classical attack we didn't anticipate, X25519 still protects you. If X25519 is broken by a quantum computer, ML-KEM still protects you. You need *both* to fail simultaneously for the handshake to be compromised.

## What Chrome Actually Does

An analysis of Chromium's BoringSSL implementation reveals the real flow:

```c
// From ssl/extensions/ext_key_share.cc (simplified)
// Chrome offers both hybrid and classical groups

static bool ext_key_share_add_clienthello(
 const SSL_HANDSHAKE *hs, CBB *out) {

 // Preferred group list, tried in order:
 // 1. X25519Kyber768Draft00 (hybrid PQ)
 // 2. X25519 (classical fallback)

 for (uint16_t group_id : hs->config->supported_group_list) {
 CBB key_exchange;
 if (group_id == SSL_GROUP_X25519_KYBER768_DRAFT00) {
 // Generate X25519 keypair
 uint8_t x25519_public[32], x25519_private[32];
 X25519_keypair(x25519_public, x25519_private);

 // Generate ML-KEM-768 encapsulation key
 uint8_t mlkem_encaps_key[MLKEM768_PUBLIC_KEY_BYTES];
 uint8_t mlkem_decaps_key[MLKEM768_SECRET_KEY_BYTES];
 MLKEM768_generate_key(mlkem_encaps_key, mlkem_decaps_key);

 // key_share = x25519_pub || mlkem_encaps_key
 CBB_add_bytes(&key_exchange, x25519_public, 32);
 CBB_add_bytes(&key_exchange, mlkem_encaps_key,
 MLKEM768_PUBLIC_KEY_BYTES);
 }
 // ...
 }
}
```

The ClientHello size increase is significant. A classical X25519-only ClientHello key\_share entry is 32 bytes. With the hybrid group, it's 32 + 1184 = 1216 bytes. Add padding and extensions, and your ClientHello balloons from ~250 bytes to ~1400+ bytes.

## The Middlebox Problem

This is where theory meets the brutal reality of the internet. Common issues encountered in practice include:

### TCP Segmentation

Many TLS ClientHellos fit in a single TCP segment (MSS ~1460 bytes on most networks). The enlarged post-quantum ClientHello sometimes requires two segments. Some middleboxes, firewalls, DPI engines, load balancers, reassemble TLS records but assume they start and end within a single TCP segment.

```
Before (classical):
[TCP segment 1: IP header + TCP header + complete ClientHello (250 bytes)]

After (hybrid PQ):
[TCP segment 1: IP header + TCP header + ClientHello part 1 (1400 bytes)]
[TCP segment 2: ClientHello part 2 (remaining bytes)]
```

The fix at the application level is TCP\_NODELAY + ensuring your TLS library sends the complete ClientHello before waiting for a response. At the infrastructure level, you need to audit every middlebox in the path.

### QUIC Has It Easier

QUIC-based TLS (used by HTTP/3) handles this more gracefully because QUIC's own framing already supports multi-packet crypto handshakes. The enlarged ClientHello fits in QUIC's initial packet which can be up to 1200 bytes of crypto payload in a 1280-byte UDP datagram. For hybrid PQ, QUIC may need two initial packets, but the protocol was designed for this from the start.

## Certificate Transparency Implications

Post-quantum doesn't change certificates *yet*, but it changes how you think about CT. The current deployment uses ML-KEM only for key exchange, authentication still uses classical ECDSA or RSA signatures. This is deliberate: key exchange is the urgent problem (HNDL attacks), while authentication is less critical because you can't "record now, forge later" a signature.

However, post-quantum signatures are coming for certificates. ML-DSA (FIPS 204, based on CRYSTALS-Dilithium) is standardized, and SLH-DSA (FIPS 205, based on SPHINCS+) is the stateless alternative. The problem? ML-DSA-65 signatures are 3,309 bytes (vs 72 bytes for ECDSA P-256). A certificate chain with three ML-DSA certificates adds ~10KB to the handshake.

```
Current certificate chain size (ECDSA):
 Leaf cert signature: 72 bytes
 Intermediate cert sig: 72 bytes
 Root cert sig: 72 bytes
 Total signatures: ~216 bytes

Future certificate chain (ML-DSA-65):
 Leaf cert signature: 3,309 bytes
 Intermediate cert sig: 3,309 bytes
 Root cert sig: 3,309 bytes
 Total signatures: ~9,927 bytes
```

This is going to make the middlebox problem 10x worse. Start planning now.

## Practical Deployment Guide

Here is how to configure nginx with post-quantum key exchange:

```nginx
# nginx.conf - requires OpenSSL 3.2+ with oqs-provider
ssl_protocols TLSv1.3;
ssl_ecdh_curve X25519Kyber768Draft00:X25519:secp256r1;

# Important: list hybrid first, classical fallback second
# Clients that don't support the hybrid group will use X25519

ssl_prefer_server_ciphers off;
# Let the client choose - they know their own capabilities
```

For Node.js applications behind the reverse proxy:

```javascript
import { createServer } from 'https';
import { readFileSync } from 'fs';

const server = createServer({
 key: readFileSync('/etc/ssl/private/server.key'),
 cert: readFileSync('/etc/ssl/certs/server.crt'),
 // Node.js 22+ with OpenSSL 3.2+
 ecdhCurve: 'X25519Kyber768Draft00:X25519',
 minVersion: 'TLSv1.3'
});
```

Verification:

```bash
# Check if your server offers hybrid PQ
openssl s_client -connect yourdomain.com:443 \
 -groups X25519Kyber768Draft00 \
 -tls1_3 2>&1 | grep "Server Temp Key"

# Expected output:
# Server Temp Key: X25519Kyber768Draft00, 1216 bits

# Check what Chrome actually negotiated (chrome://flags)
# Enable: chrome://net-internals/#security
# Look for "key_exchange_group": "X25519Kyber768Draft00"
```

## Performance: Real Numbers

Benchmarking the overhead on a production-class setup (nginx, 64-core AMD EPYC, 10Gbps):

| Metric | X25519 Only | X25519Kyber768 | Overhead |
| --- | --- | --- | --- |
| Handshake latency (p50) | 1.2ms | 1.4ms | +16% |
| Handshake latency (p99) | 3.1ms | 3.8ms | +22% |
| ClientHello size | 253 bytes | 1,438 bytes | +468% |
| ServerHello size | 105 bytes | 1,193 bytes | +1036% |
| CPU per handshake | 0.08ms | 0.12ms | +50% |
| Handshakes/sec (1 core) | 12,500 | 8,333 | -33% |

The latency overhead is negligible for most applications. The CPU overhead matters at scale but is manageable. The real issue is bandwidth, those larger handshakes add up when you're doing millions of new connections per second.

## What You Should Do Right Now

1. **Enable hybrid PQ on your edge** if your reverse proxy supports it. Cloudflare and AWS CloudFront already have it enabled by default.
2. **Audit your middleboxes** for ClientHello size tolerance. Send a 1500-byte ClientHello through your entire path and verify it arrives intact.
3. **Monitor your CT logs** for any certificates in your domain that use unexpected key types, this is a good practice regardless.
4. **Don't wait for post-quantum signatures.** Key exchange is the urgent piece. Start there.
5. **Test with real browsers.** Chrome 124+ and Firefox 128+ both support ` X25519Kyber768Draft00 `. Verify your users can connect.

The post-quantum transition is not a future problem. Chrome is already negotiating Kyber hybrid with every server that supports it. If your server doesn't offer it, your users' traffic is accumulating in some adversary's storage, waiting for the day a quantum computer can read it. The math on when to migrate isn't hard. The answer is now.

## Frequently asked questions

### What is ML-KEM (Kyber) and why is it being added to TLS?

ML-KEM (Module-Lattice-Based Key-Encapsulation Mechanism), formerly known as CRYSTALS-Kyber, is a post-quantum key encapsulation mechanism standardized by NIST in FIPS 203. It's being added to TLS to protect against harvest-now-decrypt-later attacks where adversaries record encrypted traffic today and decrypt it once quantum computers become capable.

### What is X25519Kyber768 hybrid key exchange?

It's a TLS key exchange that combines classical X25519 elliptic curve Diffie-Hellman with ML-KEM-768 (Kyber). Both algorithms contribute to the shared secret, so security holds as long as either algorithm remains unbroken, providing defense-in-depth during the quantum transition.

### Do I need to update my certificates for post-quantum TLS?

No, not yet. Current post-quantum TLS deployments only change the key exchange mechanism, not the authentication. Your existing RSA or ECDSA certificates continue to work. Post-quantum signatures in certificates (like ML-DSA) are a separate effort still in progress.

## Tips

1. **Tip:** Test the hybrid handshake with ` openssl s_client -groups X25519Kyber768Draft00 `, if you get a ` no suitable key share ` error, the server's OpenSSL is too old. It needs 3.2+ with the oqs-provider compiled in.
2. **Tip:** Monitor ClientHello sizes after enabling Kyber. They jump from ~300 bytes to ~1200 bytes, which breaks middleboxes that assume TLS handshakes fit in a single TCP segment. In one case, a corporate firewall silently dropped oversized ClientHellos for two days before the issue was caught.
3. **Tip:** Do not disable X25519 when adding the hybrid group, always offer both ` X25519Kyber768Draft00 ` and ` X25519 ` in the ` supported_groups ` extension. Servers that do not understand the hybrid group need a classical fallback or users get connection failures.
4. **Tip:** Use ` ssllabs.com ` to verify post-quantum config but do not trust it blindly, as of early 2026 it still shows Kyber hybrid as 'experimental' even though Chrome has shipped it to 100% of users.

- tls
- post-quantum
- kyber
- ml-kem
- cryptography
- x25519
- certificate-transparency

[Back to Blog](<https://lucioduran.com/blog>)
