Every time you connect to a website over HTTPS, your browser and the server run a key exchange protocol to agree on a shared secret without revealing it to anyone listening on the network. Today, most deployments use X25519 (Diffie-Hellman over Curve 25519). That will be broken by a quantum computer running Shor’s algorithm.

ML-KEM (FIPS 203, formerly CRYSTALS-Kyber) is its post-quantum replacement. It’s a Key Encapsulation Mechanism: one party generates a key pair, the other “encapsulates” a random shared secret inside a ciphertext using the public key, and the first party “decapsulates” to recover the same shared secret. The result: two parties agree on a random secret, and an eavesdropper learns nothing.

Intuition

Think of Kyber (ML-KEM) as a lockbox that anyone can close but only one party can open. In a TLS connection your browser generates a key pair and sends the open lockbox, the public key, to the website. The website puts a fresh random secret inside, closes it, and sends it back.

To an observer the closed box looks like random numbers: recovering the secret from it is a Learning With Errors problem, which no known classical or quantum algorithm solves efficiently at these sizes. Your browser holds the private key and opens the box, and both sides now share the same secret.

The Mathematical Setting

Kyber operates in the polynomial ring Rq=Z3329[x]/(x256+1)R_q = \mathbb{Z}_{3329}[x]/(x^{256}+1). Elements of this ring are polynomials with 256 coefficients, each an integer from 0 to 3328. Arithmetic is polynomial arithmetic modulo both 33293329 (for coefficients) and x256+1x^{256}+1 (to keep degree below 256).

Why q=3329q = 3329? It’s a small prime with q≡1(mod256)q \equiv 1 \pmod{256} (since 3328=28⋅133328 = 2^8 \cdot 13), so Zq\mathbb{Z}_q has primitive 256th roots of unity but no 512th ones. That is enough for an incomplete Number Theoretic Transform (NTT) for n=256n = 256: seven NTT layers split x256+1x^{256}+1 into 128 quadratic factors, and multiplication finishes with cheap products of degree-1 polynomials modulo those quadratics. The NTT turns polynomial multiplication from an O(n2)O(n^2) operation into an O(nlog⁡n)O(n \log n) one — critical for performance.

Parameter Sets

Kyber comes in three sizes, trading off security for key/ciphertext size. The module rank kk controls how many ring elements are in the key vectors:

ML-KEM-512 (k=2)

~128 bits of security (NIST Level 1). Smallest keys.

Public key: 800 bytes
Secret key: 1,632 bytes
Ciphertext: 768 bytes

Suitable for constrained environments where bandwidth matters most.

ML-KEM-768 (k=3)

~192 bits of security (NIST Level 3). Recommended default.

Public key: 1,184 bytes
Secret key: 2,400 bytes
Ciphertext: 1,088 bytes

Chrome enables it by default for TLS, as the X25519MLKEM768 hybrid.

ML-KEM-1024 (k=4)

~256 bits of security (NIST Level 5). Maximum security.

Public key: 1,568 bytes
Secret key: 3,168 bytes
Ciphertext: 1,568 bytes

Used where long-term data secrecy matters (classified, financial).

Step 1: Key Generation

The recipient generates a key pair. The public key is an LWE instance; the secret key is the hidden secret. Here’s what happens, explained plainly:

ML-KEM Key Generation

1. Generate a random 32-byte seed ρ\rho and expand it deterministically into a matrix A∈Rqk×kA \in R_q^{k \times k} using SHAKE-128. This matrix is public — anyone can expand it from the seed.

2. Sample a secret vector s∈Rqks \in R_q^k with small coefficients (drawn from the centered binomial distribution — each coefficient is between −η-\eta and +η+\eta, where η=2\eta = 2 or 3).

3. Sample a small error vector e∈Rqke \in R_q^k similarly.

4. Compute t=As+et = As + e. This is the Module-LWE sample — a noisy linear function of the secret.

Public key: (ρ,t)(\rho, t) — 1,184 bytes for ML-KEM-768.
Secret key: ss — kept private.

Step 2: Encapsulation

The sender (who only has the public key) wants to generate a shared secret and send an encrypted version of it. The process looks like a second LWE sample, but with the roles of the matrix transposed.

ML-KEM Encapsulation

1. Sample a random 32-byte message mm. Hash it together with the public key hash to derive deterministic randomness.

2. Sample a small secret rr and errors e1,e2e_1, e_2.

3. Compute:
  u=A⊤r+e1u = A^\top r + e_1 (analogous to the public key step, transposed)
  v=t⊤r+e2+encode(m)v = t^\top r + e_2 + \mathrm{encode}(m)

4. The ciphertext is (u,v)(u, v), compressed to save bytes.
The shared secret is derived from mm and the public key hash.

The sender sends the ciphertext and uses the shared secret to encrypt application data (via AES-GCM or similar).

Step 3: Decapsulation

The recipient uses their secret key to recover the shared secret from the ciphertext. The math works because the LWE noise terms are small enough to cancel out during decryption.

ML-KEM Decapsulation — Why It Works

The recipient computes v−s⊤uv - s^\top u:

v−s⊤u=(t⊤r+e2+m′)−s⊤(A⊤r+e1)v - s^\top u = (t^\top r + e_2 + m') - s^\top(A^\top r + e_1)

=(As+e)⊤r+e2+m′−s⊤A⊤r−s⊤e1= (As+e)^\top r + e_2 + m' - s^\top A^\top r - s^\top e_1

=e⊤r+e2−s⊤e1+m′= e^\top r + e_2 - s^\top e_1 + m'

The term e⊤r+e2−s⊤e1e^\top r + e_2 - s^\top e_1 is small (all the vectors involved have tiny coefficients). Rounding this to the nearest multiple of q/2q/2 recovers mm exactly, as long as the noise doesn’t exceed q/4q/4 — which the parameter choices guarantee with overwhelming probability.

Kyber noise wheel: why decryption almost never fails

Pick a bit, press “Encrypt 50 times”, then drag the noise slider up until dots start landing in the wrong half.

decodes to 0decodes to 101665 ≈ q/2q/43q/4
noise bound ≈ 3σ
±450
margin q/4
≈ 832
margin in σ units
5.5σ
failures / 50
—

Bit 0 is encoded as 0 on the wheel. Press “Encrypt 50 times” to add fresh noise to 50 encryptions of it.

What to notice: Decryption only has to decide which half of the wheel the noisy value landed in, so it tolerates any noise smaller than q/4 ≈ 832. Kyber’s small secrets and errors keep the accumulated noise many standard deviations below that margin; the failure probability is about 2−139 for ML-KEM-512 (2−164 for 768, 2−174 for 1024) — you would expect never to see one.

Security: The FO Transform

The scheme above (Kyber.PKE) is only secure against passive eavesdroppers. An attacker who can send chosen ciphertexts and observe whether decryption succeeds or fails could potentially extract the secret key. To prevent this, Kyber applies the Fujisaki-Okamoto (FO) transform — a standard compiler that converts a passively secure scheme into one that’s secure against active attackers (called CCA2 security).

The key idea: during decapsulation, the recipient re-runs encapsulation using the recovered message and checks that the result matches the received ciphertext. If it doesn’t match (someone tampered with the ciphertext), a pseudorandom value derived from the secret key and the ciphertext is returned instead. This “implicit rejection” prevents attackers from learning whether decryption succeeded, closing the chosen-ciphertext attack vector.

Implementation Choices

Centered binomial distribution

Kyber avoids discrete Gaussian sampling — which is slow and notoriously hard to implement in constant time — and instead uses the centered binomial distribution BηB_\eta: sample 2η2\eta random bits, compute their alternating sum. This is fast, simple, and statistically close enough to Gaussian for security.

Constant-time implementation

Side-channel attacks read secret information from timing variations and power consumption. Kyber is designed so all operations run in constant time regardless of secret values: NTT operations have data-independent access patterns; the FO re-encryption check uses constant-time comparison; rejection uses implicit rejection (no branch on secret data).

Performance (AVX2, x86-64)

ML-KEM-768 on modern hardware:
KeyGen: ~14 μs
Encapsulate: ~18 μs
Decapsulate: ~17 μs

For comparison, an X25519 key agreement also takes tens of microseconds, so ML-KEM’s compute cost is competitive with classical ECDH. Its real cost is bandwidth: keys and ciphertexts are about a kilobyte.

Deployment status

Google Chrome shipped X25519Kyber768 behind a flag in Chrome 116 (2023), enabled it by default on desktop in Chrome 124 (2024), and switched to the standardized X25519MLKEM768 in Chrome 131. Cloudflare, AWS, and Signal have all deployed PQC key exchange. OpenSSL 3.x and BoringSSL include ML-KEM. The migration from classical to post-quantum key exchange is already underway at internet scale.

Kyber Key Generation — Step by Step

Here is the full key generation procedure at the level of concrete arithmetic, using ML-KEM-768 (k=3k=3) as the running example. Every step maps directly to lines in the FIPS 203 specification.

Step 1 — Generate the seed. Sample 32 uniformly random bytes dd from the OS CSPRNG. Derive two 32-byte seeds by hashing: (ρ,σ)=G(d)(\rho, \sigma) = G(d) where G=SHA3-512G = \text{SHA3-512}.ρ\rho is the public seed for expanding AA; σ\sigma is the private seed for sampling secrets.

Step 2 — Expand the matrix. Generate A∈Rqk×kA \in R_q^{k \times k} by calling SHAKE-128 with input ρ∥i∥j\rho \| i \| j for each entry A[i][j]A[i][j]. Each entry is a polynomial in RqR_q with 256 uniformly random coefficients in [0,q−1][0, q-1]. AA is generated directly in NTT domain, so no forward NTT is needed. The seed ρ\rho is public — anyone can re-derive AA from it.

Step 3 — Sample the secret and error vectors. Using σ\sigma as a PRF key and a counter, sample:

s=(s0,s1,s2)∈Rq3,e=(e0,e1,e2)∈Rq3s = (s_0, s_1, s_2) \in R_q^3, \quad e = (e_0, e_1, e_2) \in R_q^3

Each polynomial’s coefficients are drawn from the centered binomial distribution BηB_\eta with η=2\eta = 2. To sample one coefficient: draw 4 bits (a0,a1,b0,b1)(a_0, a_1, b_0, b_1) and return (a0+a1)−(b0+b1)∈{−2,−1,0,1,2}(a_0 + a_1) - (b_0 + b_1) \in \{-2,-1,0,1,2\}. This avoids the need for Gaussian sampling (which is slow and hard to implement in constant time).

Step 4 — Compute the public key polynomial. Apply the NTT to ss and ee, then compute:

t=A⋅s^+e^(modq)t = A \cdot \hat{s} + \hat{e} \pmod{q}

where s^,e^\hat{s}, \hat{e} are the NTT representations of s,es, e. The product A⋅s^A \cdot \hat{s} is a matrix-vector multiplication done coefficient-wise in NTT domain — k2=9k^2 = 9 pointwise polynomial multiplications for ML-KEM-768, each consisting of 128 small degree-1 base-case products (the NTT is incomplete).

Result. The public key is (ρ,t)(\rho, t). Unlike the ciphertext, tt is not compressed: each coefficient is stored in 12 bits, so tt takes 384k384k bytes and the public key is 384k+32384k + 32 = 1,184 bytes. The secret key retains ss in NTT form plus a copy of the public key, its hash H(pk)H(\text{pk}), and a random 32-byte value zz used for implicit rejection.

Encapsulation and Decapsulation — Detailed Steps

Encapsulation and decapsulation are mirror operations. The sender uses the public key to encrypt a random 32-byte value; the receiver uses the secret key to recover it. Here is the complete arithmetic.

Encapsulation

1. Sample message. Draw 32 random bytes mm. Hash with the public key hash: (K,r)=G(m∥H(pk))(K, r) = G(m \| H(\text{pk})). The randomness rr seeds all sampling; KK is the shared secret itself.

2. Sample ephemeral secret and errors. Using rr as PRF seed, sample r′∈Rqkr' \in R_q^k and e1∈Rqke_1 \in R_q^k and e2∈Rqe_2 \in R_q from BηB_\eta.

3. Compute ciphertext components.

u=A⊤r^′+e^1(modq)u = A^\top \hat{r}' + \hat{e}_1 \pmod{q}v=t^⊤r^′+e^2+⌊q2⌉⋅m(modq)v = \hat{t}^\top \hat{r}' + \hat{e}_2 + \left\lfloor \frac{q}{2} \right\rceil \cdot m \pmod{q}

The term ⌊q/2⌉⋅m\lfloor q/2 \rceil \cdot m encodes each bit of mm as either 0 or q/2q/2 (rounded), so it sits in the middle of the coefficient range — far from both 0 and qq, giving maximum noise tolerance.

4. Compress. uu is compressed from 12-bit to 10-bit coefficients; vv from 12-bit to 4-bit. This compression is the main source of the (negligible) decryption failure probability. Total ciphertext: 1,088 bytes for ML-KEM-768.

5. Output. Ciphertext (u,v)(u, v) and shared secret KK from step 1. FIPS 203 outputs KK directly; the Round-3 Kyber submission instead ran a further SHAKE-256 KDF over KK and a hash of the ciphertext.

Decapsulation

1. Decompress and recover mm. Decompress uu and vv. Compute:

v−s⊤u=v−s^⊤u(modq)v - s^\top u = v - \hat{s}^\top u \pmod{q}

Substituting: this equals e⊤r′+e2−s⊤e1+⌊q/2⌉⋅me^\top r' + e_2 - s^\top e_1 + \lfloor q/2 \rceil \cdot m plus compression rounding errors. The noise terms are all small. Round each coefficient to 0 or q/2q/2 to recover mm bit by bit.

2. Re-encapsulate. Run the full encapsulation algorithm using the recovered mm and the stored public key. Recompute (K′,r)=G(m∥H(pk))(K', r) = G(m \| H(\text{pk})).

3. Check ciphertext equality (implicit rejection). Compare the recomputed ciphertext to the received one in constant time. If they match, output K′K' (the shared secret). If they differ (indicating a tampered or malformed ciphertext), output a pseudorandom value J(z∥c)J(z \| c) derived from the secret value zz and the ciphertext cc. The caller cannot distinguish these cases without knowing the secret key — this is the FO transform’s guarantee of CCA2 security.

Kyber in TLS — Hybrid Key Exchange

Kyber is not deployed alone in TLS. The standard approach is a hybrid key exchange: run both X25519 (classical) and ML-KEM-768 (post-quantum) in the same handshake, then combine their shared secrets cryptographically. This gives security against both a classical attacker today and a quantum attacker in the future.

The IETF standard for this is X25519MLKEM768 (RFC 10024, August 2026, built on the RFC 9954 hybrid design framework). See the hybrid TLS guide for deployment. The TLS 1.3 key share extension carries both an X25519 public key (32 bytes) and an ML-KEM-768 public key (1,184 bytes). The server responds with both shares. Each side independently derives two shared secrets and concatenates them (ML-KEM first) as the input to the TLS 1.3 key schedule:

SS=SSMLKEM768∥SSX25519  →  HKDF-Extract (TLS 1.3 key schedule)SS = SS_{\text{MLKEM768}} \| SS_{\text{X25519}} \;\to\; \text{HKDF-Extract (TLS 1.3 key schedule)}

The concatenation-then-HKDF approach ensures that if either component is secure, the combined secret is secure. An attacker who breaks X25519 (e.g., with a quantum computer) still cannot break ML-KEM-768; an attacker who breaks ML-KEM-768 (e.g., due to an unforeseen classical attack) still cannot break X25519.

In Chrome’s implementation (behind a flag in Chrome 116, on by default for desktop since Chrome 124), the combined key share adds roughly 1.2 KB to the TLS ClientHello. Benchmarks show this adds less than 1 ms of latency on a typical connection — dominated by network round-trip time, not by the cryptography.

Cloudflare’s servers support X25519MLKEM768 across their infrastructure, and AWS offers hybrid post-quantum TLS on service endpoints such as KMS, ACM and Secrets Manager. OpenSSL 3.5 includes ML-KEM-512, ML-KEM-768, and ML-KEM-1024 as named groups in the default build.

Code Example — Using ML-KEM in Practice

The following pseudocode shows the API surface of ML-KEM-768 as exposed by two real production libraries: liboqs (C, via Open Quantum Safe project) and pqcrypto (Rust). These are the libraries you would actually call in a production system today.

liboqs (C)

#include <oqs/oqs.h>

// Key generation
OQS_KEM *kem = OQS_KEM_new(OQS_KEM_alg_ml_kem_768);

uint8_t *pk = malloc(kem->length_public_key);   // 1184 bytes
uint8_t *sk = malloc(kem->length_secret_key);   // 2400 bytes
OQS_KEM_keypair(kem, pk, sk);

// Encapsulation (sender side)
uint8_t *ct        = malloc(kem->length_ciphertext);    // 1088 bytes
uint8_t *ss_sender = malloc(kem->length_shared_secret); // 32 bytes
OQS_KEM_encaps(kem, ct, ss_sender, pk);

// Decapsulation (receiver side)
uint8_t *ss_recv = malloc(kem->length_shared_secret);   // 32 bytes
OQS_KEM_decaps(kem, ss_recv, ct, sk);

// ss_sender == ss_recv: use as AES-256-GCM key
assert(memcmp(ss_sender, ss_recv, 32) == 0);

OQS_KEM_free(kem);

pqcrypto (Rust)

use pqcrypto_mlkem::mlkem768;
use pqcrypto_traits::kem::{PublicKey, SecretKey, SharedSecret, Ciphertext};

// Key generation
let (pk, sk) = mlkem768::keypair();
// pk: 1184 bytes, sk: 2400 bytes

// Encapsulation (sender)
let (ss_sender, ct) = mlkem768::encapsulate(&pk);
// ct: 1088 bytes, ss_sender: 32 bytes

// Decapsulation (receiver)
let ss_recv = mlkem768::decapsulate(&ct, &sk);
// ss_recv: 32 bytes

assert_eq!(ss_sender.as_bytes(), ss_recv.as_bytes());
// Now use ss_sender as a key for AES-256-GCM or ChaCha20-Poly1305

For hybrid TLS with Go (using the standard library’s experimental PQ support):

// Go 1.24+ includes X25519MLKEM768 in crypto/tls
// It is enabled automatically when CurvePreferences is not set.

cfg := &tls.Config{
    // X25519MLKEM768 is negotiated first when the client supports it.
    // No explicit configuration needed in Go 1.24+.
    MinVersion: tls.VersionTLS13,
}

// To verify which key exchange was used:
conn.ConnectionState().TLSUnique // or inspect via crypto/tls internals
// Chrome DevTools > Security panel shows "X25519MLKEM768" when negotiated.

Check Your Understanding

  1. Q1. Why does TLS deploy the hybrid X25519MLKEM768 instead of ML-KEM on its own?

  2. Q2. After computing v − sᵀu, the recipient has the message plus a small leftover noise term. Why can the message still be recovered exactly?

  3. Q3. What does ML-KEM decapsulation do when it receives a tampered ciphertext?

  4. Q4. Why does Kyber sample its secrets and errors from a centered binomial distribution rather than a discrete Gaussian?

Key library references for production use: