Post-quantum primitives land in the Workers Web Crypto layer

Cloudflare Workers now exposes post-quantum-resistant algorithms through Web Crypto, following the Modern Algorithms in the Web Cryptography API draft community group report. The runtime adds ML-KEM-768 and ML-KEM-1024 for key encapsulation, ML-DSA-44, ML-DSA-65 and ML-DSA-87 for signatures, the encapsulateBits(), decapsulateBits(), encapsulateKey() and decapsulateKey() operations, a getPublicKey() helper, SubtleCrypto.supports(), and JWK import/export for these algorithms.

Everything is opt-in behind the webcrypto_modern_algorithms compatibility flag while the specification is still in motion. These are building blocks for validating integrations, not a complete migration path.

Why the primitives were missing

Developers wanting to try newer post-quantum algorithms in JavaScript had two unattractive options: skip Web Crypto entirely and assemble a protocol from other pieces, or bundle a cryptographic implementation in JavaScript or WebAssembly. Both push the burden of selecting and maintaining crypto code onto implementers, inflate application size, and force that work to be repeated in every downstream library. Given the pace of the post-quantum transition, waiting for the ecosystem to converge was not viable — developers need the primitives now in order to test and evaluate integrations.

How the APIs look in practice

ML-KEM is a key encapsulation mechanism: one party holds a public key, the other encapsulates a shared secret to it, and the private key holder decapsulates to recover the same secret.

const keys = await crypto.subtle.generateKey("ML-KEM-768", true, [
  "encapsulateBits",
  "decapsulateBits",
]);

const { sharedKey, ciphertext } = await crypto.subtle.encapsulateBits(
  "ML-KEM-768",
  keys.publicKey,
);

const sameSharedKey = await crypto.subtle.decapsulateBits(
  "ML-KEM-768",
  keys.privateKey,
  ciphertext,
);

No encryption happens in that snippet. ML-KEM only yields shared key material, which a construction such as HPKE feeds into a key schedule and an AEAD like AES-GCM.

ML-DSA follows the shape most developers know from Ed25519 or ECDSA: generate a key pair, sign bytes, verify bytes.

const data = new TextEncoder().encode("hello post-quantum");

const { publicKey, privateKey } = await crypto.subtle.generateKey(
  "ML-DSA-44",
  false,
  ["sign", "verify"],
);

const signature = await crypto.subtle.sign("ML-DSA-44", privateKey, data);
const valid = await crypto.subtle.verify(
  "ML-DSA-44",
  publicKey,
  signature,
  data,
);

Both examples are hooks for primitives, not protocols.

Deriving public keys and probing support

Protocols frequently need to publish or derive a public key after a private key has been loaded, which previously meant retaining both keys or doing format-specific work. The same getPublicKey() call covers both algorithm families, with usage differing by type: ML-DSA public keys verify, ML-KEM public keys encapsulate while private keys decapsulate.

const publicKey = await crypto.subtle.getPublicKey(privateKey, ["verify"]);
const publicKey = await crypto.subtle.getPublicKey(privateKey, [
  "encapsulateBits",
]);

Because support is not uniform across runtimes, libraries should feature-detect rather than assume. SubtleCrypto.supports() serves that purpose, including for partial implementations of the modern algorithms proposal.

if (SubtleCrypto.supports("sign", "ML-DSA-44")) {
  const keys = await crypto.subtle.generateKey("ML-DSA-44", false, [
    "sign",
    "verify",
  ]);
}

Supported algorithms and implementation

The initial release supports ML-KEM-768 as a KEM and ML-DSA-44 as a signature algorithm, both requiring the compatibility flag.

// Include the following in your `wrangler.jsonc`
{
	// Opt into modern crypto algorithms
	"compatibility_flags": [
		"webcrypto_modern_algorithms"
	]
}

ML-KEM-1024, ML-DSA-65 and ML-DSA-87 are also available. ML-KEM-512 is absent because the BoringSSL version used by Workers does not expose it; rather than maintain a separate implementation of that single variant, the team started with what the native crypto library offers. Current support is tracked in the developer documentation.

Workers run on workerd, the open-source V8-based runtime. The change adds ML-KEM and ML-DSA to workerd's Web Crypto layer on top of BoringSSL primitives, along with Web Platform Tests for the modern algorithms surface, Workers-specific tests for compatibility flag behavior, and TypeScript definitions under the new Workers types.

This work was carved out of a larger proposal and covers only the first part of the modern Web Crypto algorithm specification: ML-KEM, ML-DSA, the helper APIs and JWK support. The narrower scope eases review and gives library authors something concrete to test early.

One caveat is unchanged by any of this: ML-DSA public keys and signatures are substantially larger than RSA or Ed25519 equivalents. Native integration improves performance and reduces bundling, but it does not shrink key, signature or ciphertext sizes on the wire or at rest.

Ecosystem usage

Signed JWTs illustrate the pattern. Using a panva/jose library that maps ML-DSA-* algorithms onto Web Crypto, application code is unchanged in shape:

import * as jose from "jose";

const alg = "ML-DSA-44";
const { publicKey, privateKey } = await jose.generateKeyPair(alg);

const jwt = await new jose.SignJWT({ sub: "alice" })
  .setProtectedHeader({ alg })
  .setIssuedAt()
  .setExpirationTime("5m")
  .sign(privateKey);

await jose.jwtVerify(jwt, publicKey);

The general point is delegation: libraries can hand ML-DSA operations to the runtime instead of carrying their own implementation for every environment.

ML-KEM similarly feeds into HPKE, which adds a key schedule and an AEAD to the shared key material. Libraries such as panva/hpke are already structured around Web Crypto and can use the native primitive where it exists; HPKE.CipherSuite selects implementations according to what the runtime makes available.

import * as HPKE from "hpke";

const plaintext = new TextEncoder().encode("Hello World!");

const suite = new HPKE.CipherSuite(
  HPKE.KEM_ML_KEM_768,
  HPKE.KDF_HKDF_SHA256,
  HPKE.AEAD_AES_128_GCM,
);

const recipient = await suite.GenerateKeyPair();
const sealed = await suite.Seal(recipient.publicKey, plaintext);

const opened = await suite.Open(
  recipient.privateKey,
  sealed.encapsulatedSecret,
  sealed.ciphertext,
);

This is the shape wanted for protocols like OHTTP, which is built on HPKE. If HPKE can reach a post-quantum KEM through Web Crypto, that peer can begin negotiating a ciphersuite supporting these primitives.

The same primitive set underlies work already visible elsewhere: OpenSSH shipped mlkem768x25519 support in 2024, an IETF draft covers post-quantum and hybrid KEMs for HPKE, RFC 9964 specifies ML-DSA in JOSE, an adopted draft addresses JWE with PQ and PQ/T HPKE, and HTTP Message Signatures can carry any signature algorithm the signer and verifier agree on.

Remaining gaps and next steps

The WICG proposal extends well beyond ML-KEM and ML-DSA. Not implemented in this change are SHA-3, ChaCha20-Poly1305 (with XChaCha20-Poly1305 still under discussion in the community group), cSHAKE, TurboSHAKE and HPKE. Those items need further review before they land. An implementation has already been validated against the panva/hpke and panva/jose test suites.

Whether these algorithms should eventually become default rather than opt-in remains open. For now they stay gated by the compatibility flag: the API tracks a draft, and library author feedback is wanted before it is treated as stable. Maintainers who currently bundle their own post-quantum code are the natural first testers — putting real protocol code on top of the native APIs is the fastest way to surface the rough edges.