BACK TO NEWS
defense #pqc #kyber #dilithium #nextjs #python

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.

by · · 5 min read

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:

TEXT
1shared_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.

TS
1// app/api/kem/route.ts
2import { NextResponse } from 'next/server';
3import { ml_kem768 } from '@noble/post-quantum/ml-kem';
4import { x25519 } from '@noble/curves/ed25519';
5import { hkdf } from '@noble/hashes/hkdf';
6import { sha384 } from '@noble/hashes/sha2';
7import { randomBytes } from '@noble/hashes/utils';
8 
9// Server keypair generation — done once, persisted in a KMS in production.
10function 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 
19export 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 
29export 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:

TS
1// browser
2async 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.

PYTHON
1# pqc_sign.py — issuing PQ-signed API tokens
2from pqcrypto.sign import ml_dsa_65
3from nacl.signing import SigningKey # Ed25519
4import json, time, hashlib, base64
5 
6def b64(b): return base64.urlsafe_b64encode(b).rstrip(b'=').decode()
7def ub64(s): return base64.urlsafe_b64decode(s + '=' * (-len(s) % 4))
8 
9class 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).

PHP
1<?php
2// pqc_verify.php — verify a hybrid-signed payload
3declare(strict_types=1);
4 
5require __DIR__ . '/slh_dsa_128s_verify.php'; // pure PHP, ~600 LOC
6// classical half: Ed25519 via libsodium
7 
8function 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
26header('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 
32if (!verify_hybrid($envelope['msg'], $envelope['sig'], $pub_bundle)) {
33 http_response_code(401);
34 echo json_encode(['err' => 'sig_invalid']);
35 exit;
36}
37echo 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

  1. Inventory every TLS endpoint, every key in your KMS, every place a JWT lives. Cryptography BOM (CBOM). You cannot migrate what you cannot find.
  2. 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.
  3. 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.
  4. Hybrid JWTs for inter-service auth. Inside your VPC, you control both ends; the size cost is irrelevant.
  5. Customer-facing tokens last: keep classical until browser libs are uniform, then add a pq=1 claim and accept hybrid only.
  6. Audit and retire your AES-128. Trivial sed across configs.
  7. 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-quantum is constant-time-careful; pqcrypto wraps 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

RELATED OPS