Home/Blog

Post-Quantum Cryptography Migration: A Practical Guide

Migrate to post-quantum cryptography: FIPS 203, 204 and 205, crypto inventory, Mosca's risk model, hybrid ML-KEM TLS with OpenSSL 3.5, and the deadlines.

Post-Quantum Cryptography Migration: A Practical Guide

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

SourceWhat it says
US National Security Memorandum 10 (2022)Federal goal of mitigating as much quantum risk as feasible by 2035
NSA CNSA 2.0Quantum-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:

StandardAlgorithmBased onPurpose
FIPS 203ML-KEMCRYSTALS-KyberKey encapsulation (replaces ECDH and RSA key transport)
FIPS 204ML-DSACRYSTALS-DilithiumGeneral-purpose digital signatures
FIPS 205SLH-DSASPHINCS+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 setNIST categoryEncapsulation keyCiphertextShared secret
ML-KEM-5121800 bytes768 bytes32 bytes
ML-KEM-76831,184 bytes1,088 bytes32 bytes
ML-KEM-102451,568 bytes1,568 bytes32 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 setNIST categoryPublic keySignature
ML-DSA-4421,312 bytes2,420 bytes
ML-DSA-6531,952 bytes3,309 bytes
ML-DSA-8752,592 bytes4,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:

AlgorithmPublic keySignature or ciphertextClassical counterpart
ML-KEM-7681,184 B1,088 BX25519: 32 B and 32 B
ML-DSA-651,952 B3,309 BECDSA P-256: 64 B and 64 B (raw)
SLH-DSA-256f64 B49,856 BEd25519: 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.

ComponentStatus
Chrome and EdgeX25519MLKEM768 on by default since version 131 (late 2024)
FirefoxOffers X25519MLKEM768 in current releases
Apple platformsHybrid 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
JavaML-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 CDNsHybrid 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:

WhereWhat to findHow
Network endpointsTLS versions, key exchange groups, certificate key typesActive scans of every endpoint, internal and external
CodeLibraries and algorithm choices, including hardcoded onesStatic scanning, dependency manifests
Keys and certificatesKey types, sizes, expiry, issuing CACertificate stores, keystores, KMS and HSM inventories
Products and vendorsAppliances, SaaS, firmware, HSMsWritten 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.txt

Any 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 text

Then 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 systemTypical shelf lifeExposurePriority
Health, financial and legal records in transitDecadesKey exchangeCritical
Trade secrets, deal documents, source code in transitYears to decadesKey exchangeHigh
Customer personal data in transitYearsKey exchangeHigh to medium
Firmware and code signing rootsLifetime of the device or softwareSignaturesHigh, because rotation is slow
Internal mTLS for short-lived operational dataHours to daysKey exchangeMedium
Session tokens, one-time codesMinutes to hoursSignatures or MACsLow

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:secp384r1

nginx 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-sha256

VPNs 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.crt

Use 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:

PathClientServerNegotiates todayAction
Browser to edgeCurrent browsersCDNX25519MLKEM768Monitor
Edge to originCDNLoad balancerX25519Change the load balancer TLS policy
Service to serviceOlder JDK servicesGo 1.24 servicesX25519Upgrade to JDK 27, which offers the hybrid group by default
Mobile app to APISystem networking stackEdgeDepends on OS versionTrack the negotiated group by OS version
Partner SFTPPartner clientOpenSSH 9.6curve25519-sha256Upgrade 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 ES256

Wave 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 categorySupport and prefer CNSA 2.0 byUse exclusively by
Software and firmware signing20252030
Web browsers, servers and cloud services20252033
Traditional networking equipment (VPNs, routers)20262030
Operating systems20272033
Niche equipment (constrained devices, large PKI systems)20302033
Custom applications and legacy equipmentUpdate or replace by 20332033

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 dates

What 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's CurvePreferences, Java's jdk.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.