Inside the NIST PQC suite: ML-KEM (FIPS 203), ML-DSA (FIPS 204), SLH-DSA (FIPS 205)
Four years after the round-3 finalists were named, the standards are signed. Here is what each one does, what parameter set to pick at each security level, and the smallest-possible call into each one from Python and TypeScript.
TL;DR ¶
NIST published three final standards in 2024-2025:
- FIPS 203 — ML-KEM (Module-Lattice Key Encapsulation, née Kyber)
- FIPS 204 — ML-DSA (Module-Lattice Digital Signature, née Dilithium)
- FIPS 205 — SLH-DSA (Stateless Hash-based Digital Signature, née SPHINCS+)
A fourth (FIPS 206 — FN-DSA, née Falcon) is in draft, expected 2026. Every cryptographic library you reach for must by now expose the FIPS names and parameter sets. If yours doesn't, it is out of date.
1. ML-KEM — FIPS 203 ¶
What it does: encapsulates a 32-byte shared secret under a recipient's public key. Drop-in replacement for ECDH or RSA-KEM. Used in TLS hybrid KEX, JWE key wrap, MLS group key derivation.
Security reduction: hardness of Module-LWE (Learning With Errors over module lattices). Best known classical attack: ~ 2^l/2 for parameter level l, no quantum speedup beyond Grover.
Parameter sets:
1 parameter sec. cat pub key ciphertext shared secret 2 ───────────────────────────────────────────────────────────── 3 ML-KEM-512 NIST L1 800 B 768 B 32 B 4 ML-KEM-768 NIST L3 1184 B 1088 B 32 B 5 ML-KEM-1024 NIST L5 1568 B 1568 B 32 B
Pick ML-KEM-768 by default. L3 is the recommended baseline; L5 only if you're encrypting nation-state archives.
Smallest possible call (Python) ¶
1 # pip install pqcrypto 2 from pqcrypto.kem import ml_kem_768 3 4 # Recipient generates a keypair, publishes pub. 5 pub, priv = ml_kem_768.generate_keypair() 6 # Sender encapsulates against pub. 7 ciphertext, ss_sender = ml_kem_768.encrypt(pub) 8 # Recipient decapsulates the ciphertext. 9 ss_recipient = ml_kem_768.decrypt(priv, ciphertext) 10 assert ss_sender == ss_recipient # 32 bytes of shared secret
Smallest possible call (TypeScript) ¶
1 import { ml_kem768 } from '@noble/post-quantum/ml-kem'; 2 3 const kp = ml_kem768.keygen(); // {publicKey, secretKey} 4 const { cipherText, sharedSecret: sndSS } = ml_kem768.encapsulate(kp.publicKey); 5 const rcvSS = ml_kem768.decapsulate(cipherText, kp.secretKey); 6 // sndSS === rcvSS (32 bytes)
2. ML-DSA — FIPS 204 ¶
What it does: signs an arbitrary message under a private key, verifies under a public key. Drop-in replacement for Ed25519, ECDSA, RSA-PSS. Used in JWT, code signing, OCSP, CMS.
Security reduction: Module-LWE plus Module-SIS (Short Integer Solutions). Roughly the same hardness assumption as ML-KEM.
Parameter sets:
1 parameter sec. cat pub key priv key signature 2 ────────────────────────────────────────────────────────── 3 ML-DSA-44 NIST L2 1312 B 2560 B 2420 B 4 ML-DSA-65 NIST L3 1952 B 4032 B 3293 B 5 ML-DSA-87 NIST L5 2592 B 4896 B 4595 B
Pick ML-DSA-65 for new builds. L3 is the recommended baseline.
Smallest possible call (Python) ¶
1 from pqcrypto.sign import ml_dsa_65 2 3 pub, priv = ml_dsa_65.generate_keypair() 4 msg = b"the message to sign" 5 sig = ml_dsa_65.sign(priv, msg) # 3293 bytes 6 ok = ml_dsa_65.verify(pub, msg, sig) # raises on fail
Smallest possible call (TypeScript) ¶
1 import { ml_dsa65 } from '@noble/post-quantum/ml-dsa'; 2 3 const kp = ml_dsa65.keygen(); 4 const msg = new TextEncoder().encode('the message to sign'); 5 const sig = ml_dsa65.sign(msg, kp.secretKey); 6 const ok = ml_dsa65.verify(sig, msg, kp.publicKey); // boolean
3. SLH-DSA — FIPS 205 ¶
What it does: signs by walking a deep Merkle tree of one-time signatures. Hash-function security only. The most conservative signature primitive in the suite.
Security reduction: collision and preimage resistance of SHA-2 or SHAKE. No number-theoretic assumption, no lattice assumption.
Parameter sets (each comes in s for "small signature" and f for
"fast signing"):
1 parameter sec. cat pub key signature verify 2 ────────────────────────────────────────────────────────── 3 SLH-DSA-128s L1 32 B 7856 B fast 4 SLH-DSA-128f L1 32 B 17088 B fast 5 SLH-DSA-192s L3 48 B 16224 B fast 6 SLH-DSA-192f L3 48 B 35664 B fast 7 SLH-DSA-256s L5 64 B 29792 B fast 8 SLH-DSA-256f L5 64 B 49856 B fast
s variants: smaller signature, slower signing (minutes per key).
f variants: faster signing (seconds), larger signature.
Pick SLH-DSA-128s for code-signing roots. Verification is hash-only and fast on every platform — including pure-PHP without extensions.
Smallest possible call (Python) ¶
1 from pqcrypto.sign import sphincs_sha2_128s_simple as slh 2 3 pub, priv = slh.generate_keypair() 4 msg = b"firmware image hash" 5 sig = slh.sign(priv, msg) 6 ok = slh.verify(pub, msg, sig)
4. FN-DSA (Falcon) — FIPS 206 (draft) ¶
What it does: lattice signature with the smallest signatures in the suite.
Status: draft as of mid-2026. Expected final 2026/2027.
Why you wait: Falcon depends on floating-point arithmetic in the reference implementation. Side-channel and porting hazards are real. NIST advises against deployment until FIPS 206 finalises and validated constant-time implementations exist.
When to pick it: bandwidth-constrained workloads where signature size matters more than implementation simplicity (IoT firmware updates, some HSM-internal flows).
5. The decision tree ¶
1 Need to encapsulate a key (TLS, JWE, MLS, KMS envelope)? 2 └─ ML-KEM-768 3 4 Need to sign a per-message token (JWT, OCSP, request signing)? 5 ├─ Long-lived service? → ML-DSA-65 hybrid with Ed25519 6 └─ Per-request perf? → ML-DSA-65 alone (already fast) 7 8 Need to sign a release artifact (one-shot, verified for years)? 9 └─ SLH-DSA-128s (the "100-year vault" pick) 10 11 Bandwidth-constrained signing? 12 └─ Wait for FIPS 206 (Falcon) 13 14 Pure-PHP verification on shared hosting? 15 └─ SLH-DSA-128s — verification is hash-only
6. Hybrid is still the right move ¶
NIST's own guidance: in this transition window, combine a classical algorithm with a PQ one so a break in either still leaves you safe. The standard pattern:
1 # Hybrid TLS shared secret = HKDF(X25519_ss || ML_KEM_ss, salt, info) 2 import os 3 from cryptography.hazmat.primitives import hashes 4 from cryptography.hazmat.primitives.kdf.hkdf import HKDF 5 from pqcrypto.kem import ml_kem_768 6 7 # Run both KEMs side by side. 8 pq_pub, pq_priv = ml_kem_768.generate_keypair() 9 pq_ct, pq_ss = ml_kem_768.encrypt(pq_pub) 10 x_ss = os.urandom(32) # in reality: X25519 shared secret 11 12 shared = HKDF( 13 algorithm=hashes.SHA384(), length=32, 14 salt=b'qbit404-hybrid', info=b'tls-1.3-key-derivation' 15 ).derive(x_ss + pq_ss)
This is the same construction ratified in draft-ietf-tls-hybrid-design.
A break in ML-KEM (unlikely but non-zero given the field's young age) does
not leak the shared secret because the attacker still has to break X25519
for the same session — which requires Shor, the very thing PQ migration
is preventing.
7. The deployment checklist ¶
1 [ ] CBOM (cryptography BOM) — enumerate every key. 2 [ ] Edge TLS: hybrid KEM advertised. 3 [ ] Code signing: SLH-DSA root rotation planned. 4 [ ] JWT issuers: ML-DSA hybrid emitting. 5 [ ] SDK pinning: ML-KEM / ML-DSA aliases, NOT Kyber / Dilithium aliases. 6 [ ] CAVP validation: required for FedRAMP / national security workloads.
Library matrix (mid-2026):
| Stack | KEM | Signature | Hash sig | FIPS-aliased? |
|---|---|---|---|---|
| OpenSSL ≥ 3.4 | ML-KEM | ML-DSA | SLH-DSA | yes |
| BoringSSL | ML-KEM (X25519MLKEM768) | (planned) | (planned) | yes |
| Noble (JS/TS) | ml_kem768 | ml_dsa65 | slh_dsa_sha2_128s | yes |
| pqcrypto (Python) | ml_kem_768 | ml_dsa_65 | sphincs_sha2_128s | partial |
| liboqs (C / FFI) | full matrix | full matrix | full matrix | yes |
8. References ¶
- NIST FIPS 203 (ML-KEM), FIPS 204 (ML-DSA), FIPS 205 (SLH-DSA), FIPS 206 (FN-DSA, draft)
- NIST SP 800-227 — implementing ML-KEM in TLS 1.3 (2025)
- BSI / ANSSI joint PQC migration playbook (2024)
- Hybrid Key Exchange in TLS 1.3, draft-ietf-tls-hybrid-design
The PQC suite is no longer "coming". It is published, parameter-frozen, and supported in OpenSSL and Noble. The remaining work is operational: inventory, rotate, validate. The math is done; the work is yours.
RELATED OPS
Q-day: how analysts are pinning the window between 2029 and 2034
The intelligence forecasts converge on a five-year window. We line up six published estimates side by side, show what each one assumes, and ship a Mosca-theorem calculator so you can derive your own shelf-life-aware migration deadline.
Grover bites AES: why 128 is dead and 256 is your new baseline
Grover's algorithm gives a quadratic speed-up on brute-force key search. AES-128 drops to ~64-bit effective security. AES-256 stays at ~128-bit. The fix is one config line — but every place stores cipher state, you need to find.
RSA-2048 falls: the 20M noisy-qubit estimate, redrawn
Gidney & Ekerå said ~20 million noisy qubits and ~8 hours. Three years of error-correction progress later, the number is moving — but not the conclusion. Where the 2026 resource estimates land.