Post-quantum migration means replacing RSA and elliptic-curve key exchange and signatures with the algorithms NIST standardized in August 2024: ML-KEM (FIPS 203) for key establishment, and ML-DSA (FIPS 204) and SLH-DSA (FIPS 205) for signatures. Migrate key exchange first, as a hybrid with X25519, because traffic recorded today can be decrypted once a large quantum computer exists. Hybrid key exchange is already the default in current browsers, OpenSSL 3.5, Go 1.24 and OpenSSH 10, so for many teams the first job is finding what in the path still downgrades it. Signatures and certificates come second, are harder, and depend more on your internal PKI than on public CAs.
Key takeaways
- Harvest now, decrypt later makes key exchange urgent today. Signatures only need to be quantum-safe before a quantum computer exists, so they can follow, except on long-lived devices.
- Use hybrid key exchange (X25519MLKEM768) now. It is secure if either component holds, and mainstream browsers and TLS libraries already negotiate it by default.
- Inventory first. You cannot migrate cryptography you have not found, and the hardest cases live in vendor appliances, HSMs, firmware and other teams' libraries.
- Build crypto-agility: algorithm choice in configuration, not in code, so the next change is a rollout rather than a rewrite.
- Bigger keys break things that were never tested with them: middleboxes that expect a ClientHello in one packet, header size limits, UDP paths, constrained devices.
- Public deadlines cluster around 2030 for the most important systems and 2035 for completion. Cryptographic migrations of this size take years, so the schedule is already tight.
Why this is urgent now
Harvest now, decrypt later
The quantum threat to confidentiality is retroactive. An adversary who records TLS or VPN traffic today can decrypt it later, once a cryptographically relevant quantum computer (CRQC) runs Shor's algorithm against the key exchange. Anything you send today that must stay confidential for years (health records, financial data, legal communications, trade secrets, credentials that stay valid) is exposed now, even though the computer does not exist yet.
Signatures are different. A forged signature only matters once someone can forge it, so signatures must be quantum-safe before a CRQC arrives, not before the data is recorded. That is why every serious migration plan does key exchange first.
Mosca's inequality
Michele Mosca's framing turns the question into arithmetic. Let:
- x be how long the data must stay confidential (its shelf life)
- y be how long your migration will take
- z be how long until a CRQC exists
If x plus y is greater than z, that data is already at risk. The uncomfortable term is y: migrations across products, vendors, firmware and partners take years, and they cannot start after z is known.
How close is a CRQC?
Nobody knows, and anyone quoting a precise year is guessing. What has changed is the resource estimate. In 2019, Craig Gidney and Martin Ekerå estimated that factoring RSA-2048 would take about 20 million noisy qubits running for around eight hours. In 2025, Gidney published a revised estimate of fewer than a million noisy qubits running for under a week. Hardware is still far from that, but the target is moving closer from the algorithm side as well as the hardware side. Google's late-2024 demonstration of error correction below the surface code threshold is the hardware milestone most often cited.
The deadlines governments have set
| Source | What it says |
|---|---|
| US National Security Memorandum 10 (2022) | Federal goal of mitigating as much quantum risk as feasible by 2035 |
| NSA CNSA 2.0 | Quantum-resistant algorithms phased in by system category from 2025, exclusive use by 2030 to 2033 depending on category (table below) |
| NIST IR 8547 (initial public draft, November 2024) | Proposes deprecating quantum-vulnerable public-key algorithms at the 112-bit security level after 2030 and disallowing all of them after 2035 |
| UK NCSC (March 2025) | Discovery and migration planning by 2028, priority migrations by 2031, migration complete by 2035 |
| EU coordinated roadmap (June 2025) | Member States start by the end of 2026, high-risk use cases migrated by the end of 2030, as much as feasible by 2035 |
The common shape: inventory and planning now, the most important systems around 2030, everything by 2035. If you sell to governments, defense or regulated finance, your customers' deadlines become yours sooner.
The NIST standards
NIST published three post-quantum standards on 13 August 2024:
| Standard | Algorithm | Based on | Purpose |
|---|---|---|---|
| FIPS 203 | ML-KEM | CRYSTALS-Kyber | Key encapsulation (replaces ECDH and RSA key transport) |
| FIPS 204 | ML-DSA | CRYSTALS-Dilithium | General-purpose digital signatures |
| FIPS 205 | SLH-DSA | SPHINCS+ | Hash-based signatures, the conservative backup |
More are on the way: FN-DSA, based on Falcon, as FIPS 206 (smaller signatures, harder to implement safely), and HQC, selected in March 2025 as a backup KEM built on different mathematics from ML-KEM. Stateful hash-based signatures (LMS and XMSS, approved through NIST SP 800-208) are already usable, and they are what CNSA 2.0 expects for firmware signing.
One practical warning: "Kyber" in older code and libraries is not identical to ML-KEM. The standardized algorithm changed details, so use implementations that name the FIPS algorithms (ML-KEM-768, not Kyber768) and expect the old names to disappear from libraries.
ML-KEM parameter sets
| Parameter set | NIST category | Encapsulation key | Ciphertext | Shared secret |
|---|---|---|---|---|
| ML-KEM-512 | 1 | 800 bytes | 768 bytes | 32 bytes |
| ML-KEM-768 | 3 | 1,184 bytes | 1,088 bytes | 32 bytes |
| ML-KEM-1024 | 5 | 1,568 bytes | 1,568 bytes | 32 bytes |
ML-KEM-768 is the general default and the one inside the X25519MLKEM768 TLS group. CNSA 2.0 requires ML-KEM-1024.
ML-DSA parameter sets
| Parameter set | NIST category | Public key | Signature |
|---|---|---|---|
| ML-DSA-44 | 2 | 1,312 bytes | 2,420 bytes |
| ML-DSA-65 | 3 | 1,952 bytes | 3,309 bytes |
| ML-DSA-87 | 5 | 2,592 bytes | 4,627 bytes |
SLH-DSA has small keys and large signatures: 7,856 bytes for SLH-DSA-128s, up to 49,856 bytes for SLH-DSA-256f, and signing is slow. Its security rests only on hash functions, which makes it the conservative choice for root keys and long-lived artifacts where size and speed matter less than confidence.
How the sizes compare with what you run today:
| Algorithm | Public key | Signature or ciphertext | Classical counterpart |
|---|---|---|---|
| ML-KEM-768 | 1,184 B | 1,088 B | X25519: 32 B and 32 B |
| ML-DSA-65 | 1,952 B | 3,309 B | ECDSA P-256: 64 B and 64 B (raw) |
| SLH-DSA-256f | 64 B | 49,856 B | Ed25519: 32 B and 64 B |
Symmetric cryptography and hashes
Grover's algorithm gives only a quadratic speedup against symmetric ciphers and hashes, and it parallelizes poorly. AES-256 and SHA-384 or SHA-512 are considered quantum-safe, and CNSA 2.0 specifies exactly those. Your symmetric encryption of data at rest is not the problem. The RSA or ECDH that wraps or transports its keys is.
What your stack already does
The single most useful planning fact: hybrid post-quantum key exchange is already the default in much of the software you run.
| Component | Status |
|---|---|
| Chrome and Edge | X25519MLKEM768 on by default since version 131 (late 2024) |
| Firefox | Offers X25519MLKEM768 in current releases |
| Apple platforms | Hybrid ML-KEM in the system TLS stack from the 2025 OS releases |
| OpenSSL 3.5 (LTS, April 2025) | Native ML-KEM, ML-DSA and SLH-DSA; X25519MLKEM768 in the default TLS groups |
| Go 1.24 (February 2025) | crypto/mlkem package; X25519MLKEM768 enabled by default in crypto/tls |
| Java | ML-KEM and ML-DSA APIs since JDK 24 (JEP 496 and JEP 497); X25519MLKEM768 first in the default TLS 1.3 groups since JDK 27 (September 2026, JEP 527) |
| OpenSSH 10.0 (April 2025) | mlkem768x25519-sha256 is the default key exchange |
| Cloudflare and other large CDNs | Hybrid key exchange with clients that offer it |
For traffic between a current browser and a server on a current TLS stack or CDN, the key exchange is probably already hybrid. Your exposure is concentrated in everything else: older load balancers, TLS-inspecting proxies, service-to-service traffic through older libraries, VPNs, embedded devices, partner integrations, and base images that pin an old OpenSSL.
Step 1: Build a cryptographic inventory
You cannot migrate what you have not found. Cryptography hides in four places:
| Where | What to find | How |
|---|---|---|
| Network endpoints | TLS versions, key exchange groups, certificate key types | Active scans of every endpoint, internal and external |
| Code | Libraries and algorithm choices, including hardcoded ones | Static scanning, dependency manifests |
| Keys and certificates | Key types, sizes, expiry, issuing CA | Certificate stores, keystores, KMS and HSM inventories |
| Products and vendors | Appliances, SaaS, firmware, HSMs | Written PQC roadmaps from each vendor |
Store the results in a structured format. CycloneDX 1.6 added a Cryptography Bill of Materials (CBOM) for exactly this, so the inventory can sit next to your SBOMs.
Scan TLS endpoints, including the negotiated group
Check what each endpoint negotiates, not only which certificate it presents. Run this from a host with OpenSSL 3.5 or later against a file of host:port lines. It prints the endpoint, protocol, negotiated key exchange group and certificate key type. The exact label OpenSSL prints for the group varies between versions, so the script matches both forms.
#!/usr/bin/env bash
set -uo pipefail
while IFS=: read -r host port; do
[[ -z "$host" || "$host" == \#* ]] && continue
port="${port:-443}"
out=$(echo | timeout 10 openssl s_client -connect "$host:$port" -servername "$host" \
-groups X25519MLKEM768:X25519:secp256r1 2>/dev/null)
group=$(grep -iE "negotiated tls1.3 group|server temp key" <<<"$out" | head -1 | cut -d: -f2- | xargs)
proto=$(grep -E "^ *Protocol" <<<"$out" | head -1 | awk '{print $NF}')
keyalg=$(openssl x509 -noout -text 2>/dev/null <<<"$out" | grep -m1 "Public Key Algorithm" | cut -d: -f2 | xargs)
printf '%s:%s\t%s\t%s\t%s\n' "$host" "$port" "${proto:-?}" "${group:-none}" "${keyalg:-?}"
done < endpoints.txtAny endpoint whose group is not a hybrid ML-KEM group is a harvest-now exposure for whatever crosses it.
Scan code and keys
A pattern scan is crude, but it finds most direct uses quickly:
#!/usr/bin/env python3
"""crypto_scan.py: find quantum-vulnerable algorithm references in a source tree."""
import json
import re
import sys
from pathlib import Path
PATTERNS = {
"rsa": r'\bRSA\b|rsa\.GenerateKey|RSA_generate_key|getInstance\("RSA"',
"ecdsa_ecdh": r"\bECDSA\b|\bECDH\b|ecdsa\.Sign|secp(256|384|521)r1|\bP-(256|384|521)\b|prime256v1",
"x25519_ed25519": r"\b(X25519|Ed25519)\b|ed25519\.Sign",
"dh_dsa": r"\bDiffie.?Hellman\b|\bDHE?_|\bDSA\b",
"weak_hash": r"\bMD5\b|\bSHA-?1\b",
}
EXTS = {".py", ".go", ".java", ".kt", ".js", ".ts", ".rb", ".rs", ".cs", ".c", ".h",
".yaml", ".yml", ".conf", ".tf"}
SKIP = {"node_modules", ".git", "vendor", "dist", "build", ".venv", "venv"}
findings = []
for path in Path(sys.argv[1]).rglob("*"):
if path.suffix not in EXTS or any(part in SKIP for part in path.parts):
continue
try:
lines = path.read_text(errors="ignore").splitlines()
except OSError:
continue
for n, line in enumerate(lines, 1):
for family, rx in PATTERNS.items():
if re.search(rx, line, re.IGNORECASE):
findings.append({"file": str(path), "line": n, "family": family,
"text": line.strip()[:160]})
json.dump(findings, sys.stdout, indent=2)
print(f"\n{len(findings)} findings", file=sys.stderr)Treat the output as a lead list, not a result. The uses that matter most are often indirect: a TLS library's default configuration, a JWT library choosing RS256 because the key happens to be RSA, an SSH host key on a bastion, a certificate from an internal CA. Keys and certificates need their own pass:
find /etc/ssl /etc/pki /opt -type f \( -name '*.pem' -o -name '*.crt' \) 2>/dev/null |
while read -r cert; do
alg=$(openssl x509 -in "$cert" -noout -text 2>/dev/null | grep -m1 "Public Key Algorithm" | cut -d: -f2 | xargs)
end=$(openssl x509 -in "$cert" -noout -enddate 2>/dev/null | cut -d= -f2)
[[ -n "$alg" ]] && printf '%s\t%s\t%s\n' "$cert" "$alg" "$end"
done
for k in /etc/ssh/ssh_host_*_key.pub; do ssh-keygen -lf "$k"; done
aws kms list-keys --query 'Keys[].KeyId' --output text | tr '\t' '\n' |
xargs -I{} aws kms describe-key --key-id {} \
--query 'KeyMetadata.[KeyId,KeySpec,KeyUsage]' --output textThen ask vendors. For every appliance, managed service and SaaS integration in the inventory, get a written PQC roadmap: which algorithms, which product versions, by when. Those answers set your critical path more than your own code does.
Step 2: Prioritize with Mosca, not with fear
Rank each asset by how long the protected data must stay secret, how long the system takes to change, and whether the exposure is key exchange (urgent) or signatures (later). Because nobody knows z, run the ranking against several values; the order matters more than any single answer.
#!/usr/bin/env python3
"""pqc_priority.py: rank crypto assets with Mosca's inequality (x + y > z)."""
from dataclasses import dataclass
QUANTUM_VULNERABLE = ("rsa", "ecdh", "ecdsa", "x25519", "ed25519", "dh", "dsa", "p-256", "p-384")
@dataclass
class Asset:
name: str
algorithm: str
use: str # "key_exchange", "signature" or "integrity"
shelf_life_years: int # x: how long the protected data must stay confidential
migration_years: int # y: realistic time to change this system end to end
def priority(a: Asset, z_years: int) -> str:
if not any(v in a.algorithm.lower() for v in QUANTUM_VULNERABLE):
return "none (symmetric or already post-quantum)"
if a.use == "signature":
# Forgery matters only once a CRQC exists, so compare migration time with z.
return "high" if a.migration_years >= z_years - 2 else "planned"
exposure = a.shelf_life_years + a.migration_years - z_years
if exposure > 0:
return "critical: exposed to harvest now, decrypt later"
return "high" if exposure > -3 else "medium"
inventory = [
Asset("public API TLS", "ECDHE X25519", "key_exchange", 7, 1),
Asset("partner SFTP gateway", "RSA kex", "key_exchange", 10, 3),
Asset("health record replication", "ECDHE P-256", "key_exchange", 25, 2),
Asset("device firmware signing", "ECDSA P-256", "signature", 0, 5),
Asset("session cookies", "HMAC-SHA256", "integrity", 0, 0),
]
for z in (10, 15, 20):
print(f"\nAssuming a CRQC in {z} years:")
for a in inventory:
print(f" {a.name:<28} {priority(a, z)}")The output is a ranking, not a schedule. Three patterns usually come out of it:
- Long-lived confidential data crossing a classical key exchange is critical at almost any plausible z. That is mostly TLS, VPN and replication links carrying regulated or valuable data.
- Signatures on long-lived devices rank high even though forgery is a future threat, because firmware and root keys take years to rotate through a fleet.
- Short-lived secrets rank low. Session tokens and one-time codes that expire in hours do not care about a future quantum computer.
| Data or system | Typical shelf life | Exposure | Priority |
|---|---|---|---|
| Health, financial and legal records in transit | Decades | Key exchange | Critical |
| Trade secrets, deal documents, source code in transit | Years to decades | Key exchange | High |
| Customer personal data in transit | Years | Key exchange | High to medium |
| Firmware and code signing roots | Lifetime of the device or software | Signatures | High, because rotation is slow |
| Internal mTLS for short-lived operational data | Hours to days | Key exchange | Medium |
| Session tokens, one-time codes | Minutes to hours | Signatures or MACs | Low |
Step 3: Build crypto-agility
Crypto-agility is the ability to change algorithms through configuration and rollout rather than code changes. It matters beyond this migration: hybrid is a transition state, parameter choices will be revisited, and a lattice scheme could need replacing. A codebase with algorithm names hardcoded in a hundred call sites turns each of those changes into a rewrite.
The pattern: one internal interface, a registry of implementations, selection by configuration, and an allowlist enforced at runtime. This sketch uses liboqs-python for ML-KEM and cryptography for X25519:
"""kem.py: a small crypto-agility layer. Algorithm choice lives in config, not in call sites."""
from abc import ABC, abstractmethod
import oqs
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PrivateKey, X25519PublicKey
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
class KEM(ABC):
name: str
@abstractmethod
def keygen(self) -> tuple[bytes, bytes]: ... # (public key, secret key)
@abstractmethod
def encaps(self, pk: bytes) -> tuple[bytes, bytes]: ... # (ciphertext, shared secret)
@abstractmethod
def decaps(self, sk: bytes, ct: bytes) -> bytes: ...
class MLKEM(KEM):
def __init__(self, alg: str = "ML-KEM-768"):
self.name = alg
def keygen(self):
kem = oqs.KeyEncapsulation(self.name)
pk = kem.generate_keypair()
return pk, kem.export_secret_key()
def encaps(self, pk):
return oqs.KeyEncapsulation(self.name).encap_secret(pk)
def decaps(self, sk, ct):
return oqs.KeyEncapsulation(self.name, sk).decap_secret(ct)
class X25519KEM(KEM):
name = "X25519"
def keygen(self):
sk = X25519PrivateKey.generate()
return sk.public_key().public_bytes_raw(), sk.private_bytes_raw()
def encaps(self, pk):
eph = X25519PrivateKey.generate()
shared = eph.exchange(X25519PublicKey.from_public_bytes(pk))
return eph.public_key().public_bytes_raw(), shared
def decaps(self, sk, ct):
return X25519PrivateKey.from_private_bytes(sk).exchange(X25519PublicKey.from_public_bytes(ct))
class HybridKEM(KEM):
"""Secure if either component holds. X25519 keys and ciphertexts are always 32 bytes."""
def __init__(self, classical: KEM, pq: KEM):
self.c, self.pq = classical, pq
self.name = f"{classical.name}+{pq.name}"
def keygen(self):
cpk, csk = self.c.keygen()
qpk, qsk = self.pq.keygen()
return cpk + qpk, csk + qsk
def encaps(self, pk):
cct, css = self.c.encaps(pk[:32])
qct, qss = self.pq.encaps(pk[32:])
return cct + qct, self._combine(css, qss, cct, qct)
def decaps(self, sk, ct):
css = self.c.decaps(sk[:32], ct[:32])
qss = self.pq.decaps(sk[32:], ct[32:])
return self._combine(css, qss, ct[:32], ct[32:])
def _combine(self, css, qss, cct, qct):
# Bind both ciphertexts into the derivation so neither half can be swapped.
return HKDF(algorithm=hashes.SHA384(), length=32, salt=None,
info=b"acme-hybrid-kem-v1|" + self.name.encode()).derive(qss + css + cct + qct)
REGISTRY = {
"x25519": X25519KEM,
"ml-kem-768": lambda: MLKEM("ML-KEM-768"),
"ml-kem-1024": lambda: MLKEM("ML-KEM-1024"),
"x25519+ml-kem-768": lambda: HybridKEM(X25519KEM(), MLKEM("ML-KEM-768")),
}
def kem_from_config(cfg: dict) -> KEM:
name = cfg["kem"]
if name not in cfg["allowed_kems"]:
raise ValueError(f"KEM {name!r} is not in the allowlist")
return REGISTRY[name]()The configuration decides what runs, and the allowlist decides what may run. Leaving classical-only x25519 out of the allowlist is how you stop a well-meaning change from downgrading a sensitive path:
{
"kem": "x25519+ml-kem-768",
"allowed_kems": ["x25519+ml-kem-768", "ml-kem-768", "ml-kem-1024"],
"signature": "ecdsa-p256",
"allowed_signatures": ["ecdsa-p256", "ml-dsa-65"]
}Two rules for production. First, never fall back silently: if the post-quantum half is unavailable, fail and alert, rather than continuing classical-only or substituting anything else for the missing secret. Second, the combiner above shows the shape, not something to ship for an important protocol. Use the TLS hybrid groups, HPKE with ML-KEM, or a reviewed construction such as X-Wing, which binds component keys and ciphertexts in a way that has been analyzed.
Go: ML-KEM is in the standard library
Go 1.24 added crypto/mlkem, so Go services need no third-party code for the post-quantum KEM:
package pqkem
import (
"crypto/mlkem"
"fmt"
)
// NewReceiver generates a key pair and returns the encapsulation key to publish.
func NewReceiver() (*mlkem.DecapsulationKey768, []byte, error) {
dk, err := mlkem.GenerateKey768()
if err != nil {
return nil, nil, fmt.Errorf("mlkem keygen: %w", err)
}
return dk, dk.EncapsulationKey().Bytes(), nil // 1,184 bytes
}
// Encapsulate derives a shared key and the ciphertext to send to the receiver.
func Encapsulate(encapKey []byte) (shared, ciphertext []byte, err error) {
ek, err := mlkem.NewEncapsulationKey768(encapKey)
if err != nil {
return nil, nil, fmt.Errorf("parse encapsulation key: %w", err)
}
shared, ciphertext = ek.Encapsulate() // 32-byte key, 1,088-byte ciphertext
return shared, ciphertext, nil
}
// Decapsulate recovers the same shared key on the receiver.
func Decapsulate(dk *mlkem.DecapsulationKey768, ciphertext []byte) ([]byte, error) {
return dk.Decapsulate(ciphertext)
}For TLS you rarely need this directly. Since Go 1.24, crypto/tls offers X25519MLKEM768 by default when Config.CurvePreferences is nil. If your code sets CurvePreferences explicitly, that list overrides the default, which is a common reason a Go service silently stays classical after an upgrade.
Step 4: Deploy hybrid key exchange
Hybrid key exchange runs a classical and a post-quantum algorithm together and derives the session key from both, so the connection is secure as long as either holds. It is the right default today: ML-KEM is new, X25519 is battle-tested, and the cost is bytes rather than CPU. The X25519MLKEM768 key share is 1,216 bytes from the client and 1,120 bytes from the server, against 32 bytes each way for X25519 alone.
OpenSSL 3.5 and nginx
OpenSSL 3.5 implements ML-KEM natively, so no external provider is needed, and its default TLS groups already put X25519MLKEM768 first. If you manage groups explicitly, list the hybrid first and keep classical groups for older clients. Hybrid groups exist only in TLS 1.3; TLS 1.2 clients keep negotiating classical key exchange.
openssl_conf = openssl_init
[openssl_init]
ssl_conf = ssl_sect
[ssl_sect]
system_default = system_default_sect
[system_default_sect]
Groups = X25519MLKEM768:X25519:secp256r1:secp384r1nginx built against OpenSSL 3.5 takes the same group list:
server {
listen 443 ssl;
http2 on;
server_name api.example.com;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ecdh_curve X25519MLKEM768:X25519:secp256r1:secp384r1;
ssl_certificate /etc/ssl/certs/api.example.com.fullchain.pem; # ECDSA or RSA
ssl_certificate_key /etc/ssl/private/api.example.com.key;
}Keep the certificate ECDSA or RSA. As of this writing, browsers and public CAs do not support ML-DSA certificates on the public web, and serving one to a browser fails the handshake. The harvest-now risk sits in the key exchange, which is independent of the certificate algorithm.
Verify what is actually negotiated, from outside, through the real path (CDN and load balancer included):
echo | openssl s_client -connect api.example.com:443 -servername api.example.com \
-groups X25519MLKEM768 -brief 2>&1 | grep -iE "established|group|protocol"Offering only the hybrid group makes the test unambiguous. If the handshake fails, something in the path does not support it. If a normal client negotiates X25519 even though both ends support the hybrid, something in the path prefers classical.
SSH and VPNs
OpenSSH 10.0 and later uses mlkem768x25519-sha256 by default, and 9.9 already supports it. Upgrading both ends is usually the whole job. Check what a host offers with ssh -Q kex, and pin the order on bastions that carry sensitive traffic:
KexAlgorithms mlkem768x25519-sha256,sntrup761x25519-sha512@openssh.com,curve25519-sha256VPNs move more slowly. IKEv2 has a standard for additional key exchanges (RFC 9370) that lets ML-KEM be layered on top of the classical exchange, and WireGuard deployments can mix in a pre-shared key derived from a post-quantum exchange. Vendor support varies widely, which is where the roadmap answers from your inventory earn their keep.
Internal PKI and ML-DSA certificates
Inside your own trust domain you control both ends, so you can start testing post-quantum signatures now. OpenSSL 3.5 generates ML-DSA keys and certificates directly:
openssl genpkey -algorithm ML-DSA-65 -out root.key
openssl req -x509 -new -key root.key -out root.crt -days 3650 \
-subj "/O=Acme/CN=Acme PQ Test Root" \
-addext "basicConstraints=critical,CA:TRUE" \
-addext "keyUsage=critical,keyCertSign,cRLSign"
openssl genpkey -algorithm ML-DSA-65 -out svc.key
openssl req -new -key svc.key -out svc.csr -subj "/O=Acme/CN=ledger.internal"
openssl x509 -req -in svc.csr -CA root.crt -CAkey root.key -CAcreateserial \
-out svc.crt -days 90 \
-extfile <(printf "subjectAltName=DNS:ledger.internal\nkeyUsage=critical,digitalSignature\nextendedKeyUsage=serverAuth,clientAuth")
openssl verify -CAfile root.crt svc.crtUse this in a test environment to find out what breaks: certificate parsers in older libraries, size limits in proxies and HSMs, monitoring tools that read certificates. Composite certificates, which carry a classical and a post-quantum signature together, are being standardized at the IETF. Until they settle, run parallel chains rather than inventing a format.
Step 5: Test like production
The algorithms themselves are rarely the bottleneck. ML-KEM operations are fast enough that CPU is seldom the constraint. The cost shows up as bytes on the wire and in software that was never tested with them.
What to test:
- Handshake success through every hop: CDN, load balancer, WAF, TLS-inspecting proxy, service mesh, partner gateways.
- Large ClientHellos. With a hybrid key share, the ClientHello no longer fits in one packet, which exposes servers and middleboxes that wrongly assume it does. The browser rollout of hybrid key exchange ran into exactly this, and the tldr.fail project documents the bug class.
- Header and token sizes, if post-quantum signatures will appear in tokens, cookies or forwarded client certificates.
- Tail latency (p99) on real networks, with session resumption behaving as it does in production.
- Constrained clients: embedded devices, old mobile OS versions, anything with a fixed-size buffer for keys or certificates.
A short harness keeps the algorithm comparison honest on your own hardware:
import statistics
import time
import oqs
def bench(fn, n=2000):
samples = []
for _ in range(n):
start = time.perf_counter()
fn()
samples.append((time.perf_counter() - start) * 1e6)
samples.sort()
return statistics.median(samples), samples[int(0.99 * n)]
for alg in ("ML-KEM-512", "ML-KEM-768", "ML-KEM-1024"):
rx = oqs.KeyEncapsulation(alg)
pk = rx.generate_keypair()
ct, _ = oqs.KeyEncapsulation(alg).encap_secret(pk)
enc = bench(lambda: oqs.KeyEncapsulation(alg).encap_secret(pk))
dec = bench(lambda: rx.decap_secret(ct))
print(f"{alg:<12} encaps p50={enc[0]:.0f}us p99={enc[1]:.0f}us "
f"decaps p50={dec[0]:.0f}us p99={dec[1]:.0f}us ct={len(ct)}B")
for alg in ("ML-DSA-44", "ML-DSA-65", "ML-DSA-87"):
signer = oqs.Signature(alg)
pk = signer.generate_keypair()
msg = b"x" * 256
sig = signer.sign(msg)
sign = bench(lambda: signer.sign(msg), 500)
verify = bench(lambda: oqs.Signature(alg).verify(msg, sig, pk), 500)
print(f"{alg:<12} sign p50={sign[0]:.0f}us verify p50={verify[0]:.0f}us "
f"sig={len(sig)}B pk={len(pk)}B")A compatibility matrix
Keep a matrix of every client and server type you operate, what it negotiates today and what it needs, filled in from tests rather than release notes. A few example rows of the kind a SaaS estate produces:
| Path | Client | Server | Negotiates today | Action |
|---|---|---|---|---|
| Browser to edge | Current browsers | CDN | X25519MLKEM768 | Monitor |
| Edge to origin | CDN | Load balancer | X25519 | Change the load balancer TLS policy |
| Service to service | Older JDK services | Go 1.24 services | X25519 | Upgrade to JDK 27, which offers the hybrid group by default |
| Mobile app to API | System networking stack | Edge | Depends on OS version | Track the negotiated group by OS version |
| Partner SFTP | Partner client | OpenSSH 9.6 | curve25519-sha256 | Upgrade to OpenSSH 10, agree a date with the partner |
Step 6: Migrate in waves
Wave 1: everything that terminates TLS
Load balancers, CDNs, ingress controllers, API gateways and reverse proxies come first. This wave protects the most traffic from harvest now, decrypt later, and it is mostly configuration. For each termination point: move to a TLS stack with ML-KEM, enable the hybrid group, verify the negotiated group from outside, and keep checking it so a later change cannot silently remove it. As a rollout gate in Ansible:
- name: Verify hybrid key exchange after rollout
ansible.builtin.shell: |
set -o pipefail
echo | openssl s_client -connect {{ inventory_hostname }}:443 \
-servername {{ inventory_hostname }} -groups X25519MLKEM768 -brief 2>&1
args:
executable: /bin/bash
register: pq_check
changed_when: false
failed_when: "'CONNECTION ESTABLISHED' not in pq_check.stdout"Wave 2: internal service traffic
Service meshes, internal mTLS, database connections, message brokers and replication links. These often carry the most sensitive data and run on whatever TLS library each service was built with, so this wave is a library upgrade program. A mesh helps: when sidecars terminate mTLS, upgrading the mesh's proxies covers many services at once. If you are rolling out service identity as part of a zero trust program, make the post-quantum upgrade part of the same work rather than a second pass.
Wave 3: SSH, VPNs and partner links
Covered above. Partner links are the slowest because both sides must move. Start those conversations early and put the requirement into contracts at renewal.
Wave 4: signatures, tokens and code signing
Signatures can follow key exchange, but long-lived roots cannot wait until the end.
- Code and firmware signing. Devices in the field verify updates with keys and code shipped years earlier. If a device must still verify updates after a CRQC exists, its verifier and root key must be post-quantum before it ships. CNSA 2.0 points firmware signing at LMS or XMSS; ML-DSA and SLH-DSA are the general-purpose options. Our software supply chain security guide covers where signing sits in the release pipeline.
- Tokens. An ML-DSA-65 signature is 3,309 bytes before base64 encoding, against 64 bytes for ECDSA P-256. A JWT carried in a header or cookie can cross the 8 KB header limits that many proxies and servers enforce by default. RFC 9964, published in May 2026, defines ML-DSA for JOSE and COSE. Tokens are short-lived, so their signatures can stay classical for now while you plan for size: short claims, reference tokens or server-side sessions.
- Documents and long-term signatures. Anything that must stay verifiable for decades (contracts, audit logs, archives) benefits from SLH-DSA or a dual signature now.
To see the token problem for yourself:
import base64
import json
import oqs
def b64(data: bytes) -> bytes:
return base64.urlsafe_b64encode(data).rstrip(b"=")
header = b64(json.dumps({"alg": "ML-DSA-65", "typ": "JWT"}).encode())
claims = b64(json.dumps({"sub": "user-12345", "scope": "orders:read", "exp": 1790000000}).encode())
signing_input = header + b"." + claims
signer = oqs.Signature("ML-DSA-65")
signer.generate_keypair()
token = signing_input + b"." + b64(signer.sign(signing_input))
print(len(token), "bytes") # about 4.5 KB, against a few hundred bytes with ES256Wave 5: data at rest and key management
Data encrypted with AES-256 is already fine. What needs attention is how the data keys are wrapped or exchanged: RSA key wrapping, ECIES, or keys moved between systems over classical TLS. For new designs that wrap keys to a public key, use ML-KEM in a proper KEM-DEM construction: derive the data key with a KDF, and authenticate the KEM ciphertext as part of the encryption so it cannot be swapped.
import os
import struct
import oqs
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
ALG = "ML-KEM-768"
INFO = b"acme-file-encryption-v1"
def _key(shared: bytes) -> bytes:
return HKDF(algorithm=hashes.SHA256(), length=32, salt=None, info=INFO).derive(shared)
def seal(plaintext: bytes, recipient_pk: bytes) -> bytes:
kem_ct, shared = oqs.KeyEncapsulation(ALG).encap_secret(recipient_pk)
header = INFO + struct.pack(">H", len(kem_ct)) + kem_ct
nonce = os.urandom(12)
body = AESGCM(_key(shared)).encrypt(nonce, plaintext, header) # header is authenticated
return header + nonce + body
def open_sealed(blob: bytes, recipient_sk: bytes) -> bytes:
n = len(INFO)
if blob[:n] != INFO:
raise ValueError("unknown format or version")
(ct_len,) = struct.unpack(">H", blob[n:n + 2])
end = n + 2 + ct_len
header, kem_ct = blob[:end], blob[n + 2:end]
nonce, body = blob[end:end + 12], blob[end + 12:]
shared = oqs.KeyEncapsulation(ALG, recipient_sk).decap_secret(kem_ct)
return AESGCM(_key(shared)).decrypt(nonce, body, header)Re-encrypting stored data is rarely necessary when it was encrypted symmetrically. Rewrapping the data keys is.
Cloud providers and managed keys
For managed key services, much of this is the provider's job. Symmetric KMS keys are already quantum-safe. What to check is the transport to the KMS and secrets endpoints and any asymmetric keys you use for signing or wrapping:
- The major clouds have been adding hybrid ML-KEM to the TLS endpoints of their key management and secrets services, and post-quantum signing keys to their KMS products: AWS KMS supports ML-DSA keys, and Google Cloud KMS has added quantum-safe signatures. Availability differs by service and region, so check current documentation rather than a blog post, this one included.
- Whether an SDK call actually negotiates a hybrid key exchange depends on the TLS library underneath the SDK, not on an SDK setting. A Python SDK linked against OpenSSL 3.5 offers X25519MLKEM768 by default; the same SDK on a base image with OpenSSL 3.0 does not. Check the negotiated group from the hosts that make the calls.
- Asymmetric KMS keys used for signing (code signing, JWT signing, document signing) follow the signature timeline above. Tag them in the inventory now so they are not forgotten.
The compliance timeline in detail
The NSA's CNSA 2.0 schedule for National Security Systems:
| System category | Support and prefer CNSA 2.0 by | Use exclusively by |
|---|---|---|
| Software and firmware signing | 2025 | 2030 |
| Web browsers, servers and cloud services | 2025 | 2033 |
| Traditional networking equipment (VPNs, routers) | 2026 | 2030 |
| Operating systems | 2027 | 2033 |
| Niche equipment (constrained devices, large PKI systems) | 2030 | 2033 |
| Custom applications and legacy equipment | Update or replace by 2033 | 2033 |
CNSA 2.0 also fixes the algorithms: ML-KEM-1024 for key establishment, ML-DSA-87 for signatures, LMS or XMSS for software and firmware signing, AES-256, and SHA-384 or SHA-512. That is a higher bar than the ML-KEM-768 most commercial deployments use. NSA guidance also expects new National Security System acquisitions to be CNSA 2.0 compliant from 1 January 2027. If you build for US national security customers, design for the category 5 parameter sets from the start.
Elsewhere:
- PCI DSS 4.0. Requirement 12.3.3 requires documenting the cryptographic cipher suites and protocols in use, reviewing them at least once every 12 months, monitoring industry trends on their continued viability, and keeping a documented strategy to respond to anticipated changes in cryptographic vulnerabilities. It became mandatory on 31 March 2025. That is a cryptographic inventory requirement in all but name.
- Healthcare. HIPAA does not name post-quantum algorithms, but health information keeps its sensitivity for decades, which puts replication and exchange links for health data at the top of any Mosca ranking. Our HIPAA guide covers the wider control set.
- Government suppliers. Expect post-quantum questions in security questionnaires and procurement language well before formal mandates reach your contracts.
A migration plan template
The durations are planning ranges for a mid-sized engineering organization; set your own from the inventory.
phases:
- name: discover
duration: 3 to 6 months
deliverables:
- cryptographic inventory (endpoints, code, keys, vendors) stored as a CBOM
- Mosca ranking of assets, reviewed with data owners
- written PQC roadmaps from every appliance vendor and critical SaaS provider
- a named owner and budget for the program
- name: foundations
duration: 6 to 12 months, overlapping discovery
deliverables:
- TLS stacks on versions with ML-KEM (OpenSSL 3.5 or later, Go 1.24 or later, current JDK)
- crypto-agility layer for in-house protocols, with an algorithm allowlist
- test PKI with ML-DSA chains and a compatibility matrix filled from tests
- monitoring that reports the negotiated key exchange group
- name: hybrid key exchange
duration: 6 to 18 months
deliverables:
- hybrid groups on every TLS termination point, verified from outside
- internal service traffic, SSH and VPNs moved to hybrid
- partner links scheduled, with contract language at renewal
- name: signatures and roots
duration: multi-year, starting now for long-lived devices
deliverables:
- post-quantum verification in firmware and update paths before devices ship
- ML-DSA or SLH-DSA internal roots, with parallel chains where clients allow
- token and document signing moved once standards and libraries settle
- name: retire classical-only
duration: aligned with the 2030 to 2035 deadlines
deliverables:
- classical-only key exchange disabled on sensitive paths
- an exceptions register with owners and end datesWhat goes wrong in production
- Middleboxes that choke on large ClientHellos. The hybrid key share pushes the ClientHello past one packet. Servers, firewalls and TLS-inspecting proxies that assume one packet per ClientHello fail or hang. Test every hop, and when you find a broken one, upgrade it rather than disabling the hybrid at the client.
- Silent downgrade. A TLS-inspecting proxy or an old load balancer negotiates classical X25519 on both sides, and every dashboard still shows TLS 1.3 and a green padlock. Monitor the negotiated group, not just the protocol version.
- Explicit configuration that freezes the past. Group lists set years ago (
ssl_ecdh_curve, Go'sCurvePreferences, Java'sjdk.tls.namedGroups) override new library defaults. Upgrading the library does nothing until someone edits the list. - Header and token limits. Post-quantum signatures in JWTs, cookies or forwarded client certificates blow through 8 KB header limits and fail with 431 or 400 responses, far from the change that caused them.
- UDP and MTU. QUIC and DTLS handshakes with larger keys and certificates need more datagrams. Paths that drop fragments or clamp MTU fail in ways TCP never showed.
- HSMs and firmware that cannot follow. An HSM model without ML-DSA support, or a bootloader with a fixed-size key slot, becomes the critical path. These need procurement lead time, so find them in the inventory, not at cutover.
- Home-made hybrids and silent fallbacks. Custom combiners that skip binding, or code that quietly drops to classical-only when the post-quantum library is missing. Fail closed and alert.
- Waiting for certainty. The first CRQC will not be announced in advance, and "we were waiting" is no defense for data that leaked through recorded traffic.
Frequently Asked Questions
What is post-quantum cryptography?
Post-quantum cryptography is public-key cryptography designed to resist attacks from both classical and large quantum computers. It replaces RSA and elliptic-curve algorithms, which Shor's algorithm would break, with algorithms based on problems such as module lattices and hash functions. NIST standardized the first set in August 2024: ML-KEM (FIPS 203), ML-DSA (FIPS 204) and SLH-DSA (FIPS 205).
When should we start migrating to post-quantum cryptography?
Now, starting with an inventory and with hybrid key exchange on anything that carries long-lived sensitive data. Traffic recorded today can be decrypted once a cryptographically relevant quantum computer exists, so the exposure has already begun for data that must stay confidential for years. Government timelines target the most important systems around 2030 and full migration by 2035.
What is hybrid post-quantum key exchange?
Hybrid key exchange combines a classical algorithm such as X25519 with a post-quantum one such as ML-KEM-768 and derives the session key from both. The connection stays secure as long as either algorithm is unbroken, which protects against quantum attacks and against any undiscovered weakness in the newer algorithm. In TLS 1.3 it is the X25519MLKEM768 group, enabled by default in current browsers and in OpenSSL 3.5.
Is AES-256 quantum safe?
Yes, AES-256 is considered quantum-safe. Grover's algorithm gives only a quadratic speedup against symmetric ciphers and is hard to parallelize, so AES-256 keeps a large security margin, and the NSA's CNSA 2.0 suite specifies it. The quantum risk to encrypted data lies in the RSA or elliptic-curve key exchange and key wrapping around the AES keys, not in AES itself.
Do we need new TLS certificates for post-quantum security?
Not to defend against harvest now, decrypt later. That threat targets the key exchange, which you can make hybrid while keeping ECDSA or RSA certificates. Post-quantum certificates matter for authentication once a quantum computer exists, and publicly trusted ML-DSA certificates are not yet usable with browsers, so start with internal PKI where you control both ends.
What are the CNSA 2.0 deadlines?
CNSA 2.0 is the NSA's algorithm suite for US National Security Systems. It phases in quantum-resistant algorithms by system category, with support and preference starting from 2025 and exclusive use required between 2030 and 2033 depending on the category, and it specifies ML-KEM-1024, ML-DSA-87, LMS or XMSS, AES-256 and SHA-384 or SHA-512. NSA guidance also expects new National Security System acquisitions to be compliant from 1 January 2027.
Need help with post-quantum migration?
We build and operate production systems, so we treat post-quantum migration as an operations problem as much as a cryptography one: inventory from real traffic, hybrid key exchange verified at every hop, and monitoring that catches a silent downgrade before an auditor does. If we built it, we are on the pager for it.