Up until the last few years, pretty much all web traffic was transmitted over channels that rely on elliptic curve cryptography (ECC) for key agreement. As ECC is quantum-vulnerable, this means that all of this web traffic may be subject to harvest now, decrypt later attacks. Thankfully, a hybrid key agreement scheme which utilizes both ECC and post-quantum methods can now be used in TLS 1.3. This key agreement method combines the typical X25519 Diffie–Hellman key exchange with the post-quantum key encapsulation method ML-KEM, resulting in the name X25519MLKEM768.
The simple approach to support post-quantum encryption for your web application is to upgrade your server’s TLS library to a version that supports X25519MLKEM768. Then you must hope that your users have secure, up-to-date browsers that have done the same upgrade. However, Cloudflare Radar’s latest reports show that only 37% of requests to Cloudflare are currently using X25519MLKEM768. So, taking this approach at present will secure less than half of your users.

Cloudflare Radar plot of the percentage of HTTP requests to Cloudflare over the past year that have been post-quantum-encrypted (light-blue corresponding to post-quantum encrypted)
So, what if you want to guarantee post-quantum encryption in transit for all of your users?
While building yellowpages, we wanted to guarantee post-quantum encryption for all of our users, while still allowing our application to be accessible from any browser. To achieve this, we decided to implement our own ML-KEM handshake over WebSockets. The initial connection is made from the browser to the server using a typical wss://
request, which results in TLS encryption, and then we do a further ML-KEM handshake at the application layer.

High-level flow of a WSS connection followed by an ML-KEM handshake
This guarantees hybrid post-quantum security, as we know that the TLS connection will encrypt the connection using at least ECC+AES, and that our client will further encrypt sensitive data using ML-KEM+AES. If an attacker harvests the data transmitted in our connections, they would need to break both ECC and ML-KEM to decrypt the sensitive data. We believe that a quantum attacker will someday be able strip away the ECC layer of this encryption, but there is no known quantum attack that could break through the ML-KEM layer.

The hybrid post-quantum encryption provided by our solution
Before beginning the implementation, we needed to choose the parameter sets for ML-KEM and AES. ML-KEM has three parameter sets: 512, 768, and 1024. We opted for ML-KEM-768, because we want to prioritize a high degree of security (192 bits), and the parameters have a negligible impact on our application’s performance. TLS also opted for ML-KEM-768, which gives us some added peace of mind.

The three parameter sets of ML-KEM, from FIPS-203
For AES, we chose the standard parameters of AES-GCM-256. AES may be subject to Grover’s algorithm eventually, which could bring the security of AES-256 down from 256 bits to 128 bits. We deem 128 bits of security sufficient, and for Grover’s to be much further away than the more imminent quantum threat of Shor’s algorithm.
Time to dig into our open-source production code for the ML-KEM handshake over WebSockets. We use a typical browser-based JavaScript client and a Rust server that runs in an AWS Nitro Enclave. This section will be light on prose, as we’ll let the code speak for itself.
⚠️ Warning: Rolling your own crypto
This is a custom cryptography implementation that we needed to do for yellowpages. If you don’t need to guarantee post-quantum security, you’ll likely be better off taking the simple approach mentioned earlier – use the latest version of TLS, and hope that your users do the same. But if you want to take a custom approach like us, you should also get it audited to avoid implementation errors. We had ours audited by cure53.
We use Axum for our Rust server, and they have excellent WebSocket support. We use Evervault Enclaves to host our server, and we use their TLS termination feature.
#[tokio::main]async fn main() {...let app = Router::new().route("/prove", get(handle_ws_upgrade))......}pub async fn handle_ws_upgrade(State(config): State<Config>,...ws: WebSocketUpgrade,...) -> impl IntoResponse {log::info!("Received WebSocket upgrade request");...ws.on_upgrade(move |socket| run_pq_channel_protocol(socket, config))}
We use the standard JavaScript WebSocket client.
const ws = new WebSocket(`${domains.proofService}/prove?...`);
The client generates the ML-KEM keypair, using a trusted source of entropy. For ML-KEM-768, this keypair has a 1184 byte encapsulation key (public key) and a 2400 byte decapsulation key (private key). We chose noble-post-quantum for our JavaScript post-quantum cryptography, because noble libraries are the go-to for JavaScript Web3 projects, and there are currently no audited JavaScript libraries for post-quantum.
/// api.tsmlKem768Keypair = generateMlKem768Keypair();/// cryptography.tsimport { ml_kem768 } from '@noble/post-quantum/ml-kem';...function generateMlKem768Keypair(): MlKem768Keypair {try {const keyPair = ml_kem768.keygen();return {encapsulationKey: keyPair.publicKey,decapsulationKey: keyPair.secretKey};} catch (error) {...}}
Links to source code: api code, cryptography code
We base64-encode the encapsulation key, and transmit it in a JSON message over the WebSocket connection.
const mlKem768EncapsulationKeyBase64 = base64.encode(mlKem768Keypair.encapsulationKey);// Step 3: Send handshake with ML-KEM-768 public keyconst handshakeMessage: HandshakeMessage = {ml_kem_768_encapsulation_key: mlKem768EncapsulationKeyBase64};ws.send(JSON.stringify(handshakeMessage));
The server parses the received data and runs the ML-KEM encapsulate function. This function generates the 32 byte secret key which will be shared by both parties, and encrypts it using the provided encapsulation key. In ML-KEM-768, this produces a 1088 byte ciphertext. We chose RustCrypto’s ml_kem library for our Rust server, as there is currently no audited Rust ml_kem library, and we have good trust in RustCrypto’s implementation.
pub async fn run_pq_channel_protocol(mut socket: WebSocket, config: Config) {log::info!("WebSocket connection established");// Step 1: Perform handshake and get the shared secretlet shared_secret = match perform_handshake(&mut socket).await {Ok(secret) => secret,...};...}async fn perform_handshake(socket: &mut WebSocket) -> Result<SharedKey<MlKem768>, WsCloseCode> {// Wait for message with a timeoutlet receive_result = with_timeout!(HANDSHAKE_TIMEOUT_SECS, socket.recv(), "Handshake message");...// Parse handshake messagelet handshake_request: HandshakeMessage = ok_or_bad_request!(serde_json::from_str(&handshake_text),"Failed to parse handshake message JSON");...// Decode the base64 encapsulation key from the clientlet encapsulation_key_bytes = ok_or_bad_request!(base64.decode(&handshake_request.ml_kem_768_encapsulation_key),"Failed to decode base64 encapsulation key");...// Generate the shared secret and ciphertextlet mut rng = StdRng::from_entropy();let Ok((ciphertext, shared_secret)): Result<(Ciphertext<MlKem768>, SharedKey<MlKem768>), _> =encapsulation_key.encapsulate(&mut rng)else {...};...}
🔐 At this point, the server has the ML-KEM-agreed secret key which will be possessed by both parties, and no one else.
We base64-encode the ciphertext, and transmit it back to the client in JSON over the WebSocket.
async fn perform_handshake(socket: &mut WebSocket) -> Result<SharedKey<MlKem768>, WsCloseCode> {...// Encode the ciphertext to base64let ciphertext_base64 = base64.encode(ciphertext);// Create and send the responselet handshake_response = HandshakeResponse {ml_kem_768_ciphertext: ciphertext_base64,auth_attestation_doc,};...ok_or_internal_error!(socket.send(WsMessage::Text(response_json.into())).await,"Failed to send handshake response");...}
The client parses the received ciphertext, and runs the ML-KEM decapsulate function on it using the decapsulation key. This decrypts the ciphertext to uncover the shared secret.
/// api.tsconst handshakeResponse = await raceWithTimeout<HandshakeResponse>(...);// Step 5: Validate and decode the ciphertextconst mlKem768CiphertextBase64 = handshakeResponse.ml_kem_768_ciphertext;...// Decode base64 to bytesconst mlKem768CiphertextBytes = base64.decode(mlKem768CiphertextBase64) as MlKem768CiphertextBytes;/// cryptography.tsmlKemSharedSecret = deriveMlKem768SharedSecret(mlKem768CiphertextBytes,mlKem768Keypair);...function deriveMlKem768SharedSecret(ciphertextBytes: MlKem768CiphertextBytes,keypair: MlKem768Keypair): Uint8Array {...try {...// Derive the shared secretsharedSecret = ml_kem768.decapsulate(ciphertextBytes,keypair.decapsulationKey);...return sharedSecret;}...}
Links to source code: api.ts, cryptography.ts
🔐 At this point, the client also has the ML-KEM-agreed secret key
From now on, the client is safe to transmit sensitive data to the server, provided they AES-encrypt the data using the agreed secret key. We chose to use noble-ciphers for AES in JavaScript, which has been audited.
/// api.tsconst aes256GcmEncryptedMessage = encryptProofRequestData(proofRequestBytes,mlKem768Keypair,mlKem768CiphertextBytes);...// Send the encrypted proof request as a binary messagews.send(aes256GcmEncryptedMessage);/// cryptography.tsfunction encryptProofRequestData(requestBytes: ProofRequestBytes,mlKem768Keypair: MlKem768Keypair,mlKem768CiphertextBytes: MlKem768CiphertextBytes): Uint8Array {...try {// Derive the shared secretmlKemSharedSecret = deriveMlKem768SharedSecret(mlKem768CiphertextBytes,mlKem768Keypair);...// Generate a random 96-bit (12-byte) nonce for AES-256-GCMaes256GcmNonce = randomBytes(AES_256_GCM_NONCE_SIZE);// Encrypt using AES-256-GCM with the ML-KEM derived shared secret as keyconst aes256Gcm = gcm(mlKemSharedSecret, aes256GcmNonce);const aes256GcmCiphertext = aes256Gcm.encrypt(requestBytes);// Combine nonce and encrypted data into a single buffer// Format: [12 bytes nonce][N bytes ciphertext]const aes256GcmEncryptedMessage = new Uint8Array(AES_256_GCM_NONCE_SIZE + aes256GcmCiphertext.length);aes256GcmEncryptedMessage.set(aes256GcmNonce);aes256GcmEncryptedMessage.set(aes256GcmCiphertext, AES_256_GCM_NONCE_SIZE);return aes256GcmEncryptedMessage;}...}
Links to source code: api.ts, cryptography.ts
The server can use AES-256-GCM to decrypt the data transmitted by the client. We chose to use RustCrypto’s aes-gcm crate, which has been audited.
pub async fn run_pq_channel_protocol(mut socket: WebSocket, config: Config) {...// Step 2: Receive the proof requestlet proof_request = match receive_proof_request(&mut socket, shared_secret).await {Ok(request) => request,...};...}...async fn receive_proof_request(socket: &mut WebSocket,shared_secret: SharedKey<MlKem768>,) -> Result<ProofRequest, WsCloseCode> {// Wait for message with a timeoutlet receive_result = with_timeout!(PROOF_REQUEST_TIMEOUT_SECS, socket.recv(), "Proof request");...// Extract nonce and ciphertextlet (aes_256_gcm_nonce_bytes, aes_256_gcm_ciphertext) =aes_256_gcm_encrypted_data.split_at(AES_GCM_NONCE_LENGTH);let aes_256_gcm_nonce = Aes256GcmNonce::from_slice(aes_256_gcm_nonce_bytes);...// Create AES-GCM cipher using the shared secretlet aes_256_gcm_key = Aes256GcmKey::<Aes256Gcm>::from_slice(&shared_secret);let aes_256_gcm_cipher = Aes256Gcm::new(aes_256_gcm_key);// Decrypt the datalet decrypted_bytes = ok_or_bad_request!(aes_256_gcm_cipher.decrypt(aes_256_gcm_nonce, aes_256_gcm_ciphertext),"Failed to decrypt proof request");...}
This guarantees post-quantum security in transit for all yellowpages users! If you want to try it out, you can register to yellowpages and open Firefox’s network tab to view the WebSocket messages.
Here’s the full flow from start to finish:

Full flow diagram for ML-KEM over WebSockets