Post-quantum cryptography in production: Next.js, Python and PHP, the working code
Which PQC primitives to actually deploy in 2026, why hybrid is the only honest choice today, and copy-pasteable implementations of ML-KEM and ML-DSA across three stacks. With benchmarks and migration notes.
TL;DR ¶
NIST finalized ML-KEM (FIPS 203) for key encapsulation, ML-DSA (FIPS 204) for digital signatures, and SLH-DSA (FIPS 205) for hash-based signatures. FALCON (FIPS 206) is still in draft. For 2026, the safe stance is hybrid: classical primitive XOR'd with a PQ primitive, so that breaking the result requires breaking both. This post is the working code, in three stacks, for the most common workloads: TLS-like KEM, document signing, API token issuance.
If the library you reach for does not say ML-KEM / ML-DSA, it is out of date. The names changed from Kyber / Dilithium when the standards landed.
1. The shortlist ¶
| Workload | Use | Avoid |
|---|---|---|
| Key exchange / KEM | ML-KEM-768 (NIST L3) hybrid with X25519 | Pure Kyber-512, pure RSA |
| Digital signatures | ML-DSA-65 (NIST L3) hybrid with Ed25519 | RSA-PSS, ECDSA in isolation |
| Hash-based signatures (firmware, roots) | SLH-DSA-128s (small sig) or SLH-DSA-128f (fast sig) | XMSS without state hygiene |
| Symmetric | AES-256-GCM, ChaCha20-Poly1305 | AES-128 |
| Hash | SHA-384, SHA3-384, BLAKE3 | Pure SHA-256 for long-lived commits |
ML-KEM is what you wire into your TLS, your envelope encryption, your session establishment. ML-DSA is what you wire into your JWTs, your code-signing, your client auth. SLH-DSA is what you wire into the things you sign once and want to verify in 2080.
2. Why hybrid ¶
PQ primitives are young. SIKE (2022) was a NIST round-4 candidate. Within a year it was broken on a laptop. Rainbow (2022) made it to round 3, then fell. The mainline lattice schemes (Kyber/Dilithium/Falcon) have survived strong scrutiny, but the field is moving. Hybrid pays a 2× wire overhead and removes the single-point-of-failure problem.
The construction:
1 shared_secret = KDF( X25519_ss || ML-KEM_ss || transcript )
Both inputs are cryptographically committed. Breaking either gives the attacker nothing without also breaking the other.
3. Next.js — a hybrid KEM endpoint ¶
Use @noble/post-quantum for ML-KEM and @noble/curves for X25519. Both are audited, dependency-free TypeScript. The kit in downloads is a working Next.js Route Handler.
1 // app/api/kem/route.ts 2 import { NextResponse } from 'next/server'; 3 import { ml_kem768 } from '@noble/post-quantum/ml-kem'; 4 import { x25519 } from '@noble/curves/ed25519'; 5 import { hkdf } from '@noble/hashes/hkdf'; 6 import { sha384 } from '@noble/hashes/sha2'; 7 import { randomBytes } from '@noble/hashes/utils'; 8 9 // Server keypair generation — done once, persisted in a KMS in production. 10 function loadServerKeys() { 11 const kemSeed = process.env.PQC_KEM_SEED!; // 64 hex chars 12 const xSeed = process.env.PQC_X25519_SEED!; 13 const kemKp = ml_kem768.keygen(hexToBytes(kemSeed)); 14 const xPriv = hexToBytes(xSeed); 15 const xPub = x25519.getPublicKey(xPriv); 16 return { kemKp, xPriv, xPub }; 17 } 18 19 export async function GET() { 20 // Publish both public keys; client encapsulates against both. 21 const { kemKp, xPub } = loadServerKeys(); 22 return NextResponse.json({ 23 suite: 'hybrid-x25519-mlkem768', 24 kem_pub: bytesToB64(kemKp.publicKey), 25 x_pub: bytesToB64(xPub), 26 }); 27 } 28 29 export async function POST(req: Request) { 30 const body = await req.json(); 31 const { kemKp, xPriv } = loadServerKeys(); 32 33 const kemCt = b64ToBytes(body.kem_ct); 34 const xClient = b64ToBytes(body.x_client_pub); 35 36 const ssKem = ml_kem768.decapsulate(kemCt, kemKp.secretKey); 37 const ssX = x25519.getSharedSecret(xPriv, xClient); 38 39 const transcript = sha384(concat(b64ToBytes(body.kem_ct), xClient)); 40 const sharedSecret = hkdf(sha384, concat(ssX, ssKem), transcript, 'qbit404-hybrid', 32); 41 42 // sharedSecret is now your AES-256-GCM session key 43 return NextResponse.json({ ok: true, sid: bytesToB64(sha384(sharedSecret).slice(0, 16)) }); 44 }
Client side, on first contact:
1 // browser 2 async function establish(serverPubs: { kem_pub: string; x_pub: string }) { 3 const { ml_kem768 } = await import('@noble/post-quantum/ml-kem'); 4 const { x25519 } = await import('@noble/curves/ed25519'); 5 6 const xClientPriv = crypto.getRandomValues(new Uint8Array(32)); 7 const xClientPub = x25519.getPublicKey(xClientPriv); 8 9 const { cipherText, sharedSecret: ssKem } = 10 ml_kem768.encapsulate(b64ToBytes(serverPubs.kem_pub)); 11 12 const ssX = x25519.getSharedSecret(xClientPriv, b64ToBytes(serverPubs.x_pub)); 13 // ... derive session key identically to server 14 return { ssKem, ssX, kemCt: cipherText, xClientPub }; 15 }
Wire size: ML-KEM-768 ciphertext is 1088 B, public key 1184 B. X25519 adds 32 B. Total handshake is dominated by the KEM. Cache the server public bundle aggressively.
Benchmark (M2 MBP, Node 20): keygen 0.6 ms, encaps 0.7 ms, decaps 0.7 ms. Faster than ECDH on the same machine.
4. Python — ML-DSA signing service ¶
Use pqcrypto (wraps PQClean's reference impls). For production, prefer liboqs-python if you need NIST-CAVP-validated artifacts.
1 # pqc_sign.py — issuing PQ-signed API tokens 2 from pqcrypto.sign import ml_dsa_65 3 from nacl.signing import SigningKey # Ed25519 4 import json, time, hashlib, base64 5 6 def b64(b): return base64.urlsafe_b64encode(b).rstrip(b'=').decode() 7 def ub64(s): return base64.urlsafe_b64decode(s + '=' * (-len(s) % 4)) 8 9 class HybridSigner: 10 def __init__(self, ed_seed: bytes, ml_dsa_pub: bytes, ml_dsa_priv: bytes): 11 self.ed = SigningKey(ed_seed) 12 self.pq_pub = ml_dsa_pub 13 self.pq_priv = ml_dsa_priv 14 15 @classmethod 16 def generate(cls, ed_seed: bytes): 17 pq_pub, pq_priv = ml_dsa_65.generate_keypair() 18 return cls(ed_seed, pq_pub, pq_priv) 19 20 def issue(self, claims: dict) -> str: 21 header = {'alg': 'EdDSA+ML-DSA-65', 'typ': 'JWT-HYBRID'} 22 payload = {**claims, 'iat': int(time.time())} 23 h_b = b64(json.dumps(header, separators=(',',':')).encode()) 24 p_b = b64(json.dumps(payload, separators=(',',':')).encode()) 25 msg = f"{h_b}.{p_b}".encode() 26 27 sig_ed = self.ed.sign(msg).signature 28 sig_pq = ml_dsa_65.sign(msg, self.pq_priv) 29 return f"{h_b}.{p_b}.{b64(sig_ed)}.{b64(sig_pq)}" 30 31 def verify(self, token: str) -> dict | None: 32 try: 33 h, p, s_ed, s_pq = token.split('.') 34 msg = f"{h}.{p}".encode() 35 self.ed.verify_key.verify(msg, ub64(s_ed)) 36 ml_dsa_65.verify(msg, ub64(s_pq), self.pq_pub) 37 return json.loads(ub64(p)) 38 except Exception: 39 return None
Wire size: ML-DSA-65 signature is 3293 bytes. With Ed25519's 64-byte signature you are at ~4.4 KB total signature material per token. For JWT use cases this is large — your auth header is now ~6 KB after base64. Consider a server-side session cookie referencing a server-held PQ token instead of pushing the token to the browser.
Benchmark (M2 MBP, Python 3.12): sign 1.4 ms, verify 0.6 ms.
5. PHP — backwards-compatible signed messages ¶
PHP 8.x has no native PQ. Use openssl_pkey_ed25519 for the classical half and libsodium-php with the experimental ML-KEM extension, or shell out to a verified binary. The kit in downloads ships a liboqs FFI binding plus a pure-PHP SLH-DSA verifier (signatures only — verification is hash-only, so it's PHP-implementable).
1 <?php 2 // pqc_verify.php — verify a hybrid-signed payload 3 declare(strict_types=1); 4 5 require __DIR__ . '/slh_dsa_128s_verify.php'; // pure PHP, ~600 LOC 6 // classical half: Ed25519 via libsodium 7 8 function verify_hybrid(string $message, array $sig, array $pub): bool { 9 // sig: ['ed' => b64, 'slh' => b64] 10 // pub: ['ed' => b64, 'slh' => b64] 11 $ed_ok = sodium_crypto_sign_verify_detached( 12 base64_decode($sig['ed']), 13 $message, 14 base64_decode($pub['ed']) 15 ); 16 if (!$ed_ok) return false; 17 18 return SlhDsa128sVerify::verify( 19 $message, 20 base64_decode($sig['slh']), 21 base64_decode($pub['slh']) 22 ); 23 } 24 25 // Example usage in a webhook receiver 26 header('Content-Type: application/json'); 27 $raw = file_get_contents('php://input'); 28 $envelope = json_decode($raw, true); 29 30 $pub_bundle = json_decode(file_get_contents(__DIR__ . '/peer_pubs.json'), true); 31 32 if (!verify_hybrid($envelope['msg'], $envelope['sig'], $pub_bundle)) { 33 http_response_code(401); 34 echo json_encode(['err' => 'sig_invalid']); 35 exit; 36 } 37 echo json_encode(['ok' => true]);
Why SLH-DSA on PHP: the verification is pure hash (SHA-256 underneath), so you can ship a dependency-free PHP verifier on shared hosting. Signing is heavier (you want a Python/Rust signer process for that); verification on PHP web hooks is fine.
Wire size: SLH-DSA-128s signature is 7856 bytes. Big. Only use it where the signed-once-verified-often model dominates: release artifacts, SBOMs, code provenance.
6. Migration order, practical ¶
- Inventory every TLS endpoint, every key in your KMS, every place a JWT lives. Cryptography BOM (CBOM). You cannot migrate what you cannot find.
- Add hybrid TLS at the edge (Cloudflare, fastly, ALB) — almost always a config flag in 2026. Free win, no client changes for browsers that speak it.
- Rotate code-signing roots to SLH-DSA. This is the highest-impact migration because the artifacts you sign today will be verified for 10-20 years.
- Hybrid JWTs for inter-service auth. Inside your VPC, you control both ends; the size cost is irrelevant.
- Customer-facing tokens last: keep classical until browser libs are uniform, then add a
pq=1claim and accept hybrid only. - Audit and retire your AES-128. Trivial sed across configs.
- Document a quarterly migration review. Bake it into your security calendar; PQ standards will iterate.
7. Pitfalls ¶
- Wrong parameter sets: Kyber-512 is a NIST L1 set, fine for ephemeral handshakes, not fine for long-term confidentiality. Default to L3 (ML-KEM-768, ML-DSA-65).
- Non-constant-time bigints in JS implementations: do not roll your own.
@noble/post-quantumis constant-time-careful;pqcryptowraps PQClean reference, which is best-effort. - State reuse in stateful schemes (XMSS): catastrophic. SLH-DSA is stateless precisely to dodge this footgun.
- Mixing pre-standardization Kyber with FIPS 203 ML-KEM: they are not wire-compatible. Check your library's release notes.
- Floating-point Falcon implementations on non-IEEE-754 hardware: subtle bugs. Use the FFI binding to a vetted C impl.
- Random number generation: PQ amplifies the cost of a bad RNG. Use OS sources only.
8. Reading list ¶
- NIST FIPS 203, 204, 205 (2024)
- PQC migration playbook, BSI / ANSSI joint (2024)
- Hybrid Key Exchange in TLS 1.3, draft-ietf-tls-hybrid-design
- Bernstein, Buchmann, Dahmen — Post-Quantum Cryptography (book, still the best primer)
"Hybrid is what you pick when you have not made up your mind. We have not, and you should not pretend we have." — A NIST evaluator, off the record, 2024.
The kits in downloads are MIT-licensed and self-contained. Drop them into a repo, run the included examples/ directory, and you have a working PQ surface in an afternoon.
RELATED OPS
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.
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.