https://localhost:8443X25519 + ML-KEM-768 combined shared secret derives the AES-256-GCM key that encrypts all API traffic
X25519MLKEM768JWT tokens are signed with ML-DSA-65 via Qudo provider — quantum-safe authentication, not classical HMAC
ML-DSA-65 (FIPS 204)Each TLS session uses a fresh hybrid key exchange — past sessions stay safe even if long-term keys are compromised
ML-KEM-768 (FIPS 203)This service demonstrates two layers of quantum-safe protection for REST APIs:
Transport: TLS 1.3 with hybrid key exchange (X25519 + ML-KEM-768) via the Qudo provider. The AES-256-GCM session key is derived from both classical and post-quantum shared secrets — an attacker must break both to decrypt.
Authentication: JWT tokens signed with ML-DSA-65 (FIPS 204) via the Qudo provider. Asymmetric keypair replaces classical HMAC-SHA256 — sign with private key, verify with public key.
/api/hello
Hello / connectivity check (public)
/api/health
Service health + Qudo provider status (public)
/api/auth/token
Generate ML-DSA-65 signed JWT token
/api/auth/verify
Verify a JWT signature (ML-DSA-65)
/api/auth/jwks
Get ML-DSA-65 public key (JWKS)
/api/secure
Protected endpoint — requires ML-DSA-65 JWT
/api/crypto-info
Discover available PQC algorithms — requires JWT
Migrate your REST APIs to quantum-safe cryptography. Use this playground to understand PQC API patterns, then apply the Qudo provider to your own APIs — two changes to your infrastructure, zero changes to your business logic.
admin/admin) so protected endpoints work with one click. Your production client must not hard-code credentials — authenticate through your real identity provider (OIDC, SAML, etc.), call /api/auth/token with real credentials, store the JWT, and attach it as Authorization: Bearer <token> on each request. The JWT signing logic (ML-DSA-65 via Qudo) is what you migrate; the login UX is yours.
Two layers of PQC protection are applied to your REST APIs. Both use the Qudo provider — no application code rewrite needed.
TLS 1.3 with X25519MLKEM768 — combines classical X25519 with post-quantum ML-KEM-768 (FIPS 203). The AES-256-GCM session key is derived from both, so an attacker must break both to decrypt. One line change in your NGINX config.
JWT tokens signed with ML-DSA-65 (FIPS 204) instead of HMAC-SHA256 or ECDSA.
Asymmetric keypair: sign with private key, verify with public key via provider.sign() and provider.verify().
The public key is published at a JWKS endpoint for clients to verify independently.
Client Your Reverse Proxy (NGINX) Your REST API
| | |
|-- TLS 1.3 ClientHello ------>| |
| (offers X25519MLKEM768) | |
| | |
|<- TLS 1.3 ServerHello -------| |
| (selects X25519MLKEM768) | |
| | |
| Hybrid Key Exchange: | |
| X25519 + ML-KEM-768 | |
| = AES-256-GCM session key | |
| | |
|-- POST /auth/token ---------->|-- HTTP ---------------------->|
| (encrypted via AES-256-GCM) | |
| | provider.sign(payload, privKey,|
| | "ML-DSA-65") -> JWT |
|<- { token: "ey..." } --------|<- HTTP ----------------------- |
| (encrypted via AES-256-GCM) | |
| | |
|-- GET /secure --------------->|-- HTTP + JWT ---------------->|
| Authorization: Bearer <JWT> | provider.verify(token, pubKey,|
| (encrypted via AES-256-GCM) | "ML-DSA-65") -> true/false |
|<- { data } ------------------|<- HTTP ----------------------- |
Three changes to make your existing REST API quantum-safe. Your API endpoints, business logic, and database stay exactly the same.
What changes: Your NGINX/load balancer config (1 line)
What doesn't change: Your application code, endpoints, business logic
ssl_protocols TLSv1.2 TLSv1.3; ssl_ecdh_curve auto; # Default: X25519 or P-256 key exchange
ssl_protocols TLSv1.3; ssl_ecdh_curve X25519MLKEM768:X25519:P-384; # Hybrid PQC: X25519 + ML-KEM-768 # Fallback: X25519, P-384 for older clients
Prerequisite: OpenSSL 3.6+ with Qudo provider installed. NGINX must be built against this OpenSSL.
What changes: Your token generation and verification logic
What doesn't change: JWT format (still header.payload.signature), your API endpoints, your auth flow
// Shared secret (symmetric, quantum-vulnerable)
header: {"alg": "HS256"}
sign: HMAC-SHA256(sharedSecret, data)
verify: HMAC-SHA256(sharedSecret, data) == sig
// Asymmetric keypair (quantum-safe, FIPS 204)
header: {"alg": "ML-DSA-65"}
sign: provider.sign(data, privKey, "ML-DSA-65")
verify: provider.verify(data, sig, pubKey, "ML-DSA-65")
import com.qudo.crypto.QudoCrypto;
import com.qudo.crypto.QudoKeyPair;
import java.util.Base64;
// ============================================================
// Initialize once at application startup
// ============================================================
QudoCrypto provider = QudoCrypto.create();
// Generate ML-DSA-65 keypair (do this once, store securely)
QudoKeyPair jwtKeys = provider.generateKeyPair("ML-DSA-65");
byte[] privateKey = jwtKeys.getPrivateKeyPem();
byte[] publicKey = jwtKeys.getPublicKeyPem(); // publish via /jwks
// ============================================================
// Token generation (your /auth/token endpoint)
// ============================================================
String header = Base64.getUrlEncoder().withoutPadding()
.encodeToString("{\"alg\":\"ML-DSA-65\",\"typ\":\"JWT\"}".getBytes());
String payload = Base64.getUrlEncoder().withoutPadding()
.encodeToString(("{\"sub\":\"" + username + "\",\"iat\":" + now + ",\"exp\":" + (now+3600) + "}").getBytes());
byte[] dataToSign = (header + "." + payload).getBytes();
byte[] signature = provider.sign(dataToSign, privateKey, "ML-DSA-65");
String token = header + "." + payload + "." +
Base64.getUrlEncoder().withoutPadding().encodeToString(signature);
// ============================================================
// Token verification (your auth filter / middleware)
// ============================================================
String[] parts = token.split("\\.");
byte[] signedData = (parts[0] + "." + parts[1]).getBytes();
byte[] sig = Base64.getUrlDecoder().decode(parts[2]);
boolean valid = provider.verify(signedData, sig, publicKey, "ML-DSA-65");
// valid == true -> allow request, extract claims from parts[1]
com.qudo:qudo-jni-crypto to your Maven/Gradle build and set -Djava.library.path to the native library location. The provider handles all PQC operations natively via OpenSSL FIPS.
What changes: Your server's OpenSSL installation
What doesn't change: Everything else
# 1. Install Qudo provider cd qudo-pqc-v0.1.0-macos-arm64 sudo ./install.sh # 2. Run FIPS install tool sudo qudo_fipsinstall -module /path/to/qudo-fips.dylib -out /path/to/qudofipsmodule.cnf # 3. Update openssl.cnf to load the provider # Add to provider section: # qudoprovider = qudo_sect # Include the FIPS config: # .include qudofipsmodule.cnf # 4. Verify openssl list -providers # Should show: qudoprovider - OpenSSL Qudo Provider - active
| Component | Changes? | What to do |
|---|---|---|
| NGINX config | Yes — 1 line | Add ssl_ecdh_curve X25519MLKEM768:X25519:P-384 |
| JWT signing | Yes — use Qudo provider | provider.sign(data, privKey, "ML-DSA-65") instead of HMAC/ECDSA |
| JWT verification | Yes — use Qudo provider | provider.verify(data, sig, pubKey, "ML-DSA-65") |
| OpenSSL | Yes — install provider | Install Qudo Cryptographic Module, update openssl.cnf |
| API endpoints | No | No changes to routes, request/response formats |
| Business logic | No | Application code stays the same |
| Database | No | No schema changes |
| Client HTTP code | No | TLS 1.3 clients get PQC automatically |
| Client JWT verification | Yes* | Consumers need ML-DSA-65 verification (Qudo provider or your JWKS endpoint) |
openssl s_client -connect localhost:8443 -tls1_3 -groups X25519MLKEM768 -brief 2>&1 | grep "Negotiated" # Expected: Negotiated TLS1.3 group: X25519MLKEM768
# Decode JWT header (first part before the dot)
echo $TOKEN | cut -d. -f1 | base64 -d 2>/dev/null
# Expected: {"alg":"ML-DSA-65","typ":"JWT"}
# Capture TLS handshake sudo tcpdump -i lo0 -w /tmp/pqc.pcap port 8443 # Open in Wireshark, filter: tls.handshake.type == 1 # In ClientHello -> Supported Groups: look for 0x4588 (X25519MLKEM768)
| Operation | PQC | Classical | PQC Size | Classical Size |
|---|---|---|---|---|
| TLS Handshake (first request) | ~65ms | ~5ms | N/A | N/A |
JWT sign (provider.sign()) | ~70ms | ~1ms (HMAC) | ~4,500 chars | ~300 chars |
JWT verify (provider.verify()) | ~5ms | <1ms | N/A | N/A |
| Subsequent TLS requests | ~0ms overhead | ~0ms | N/A | N/A |
provider.sign() per API requestlarge_client_header_buffers 4 16k; to your NGINX config| Component | Minimum Version | Notes |
|---|---|---|
| OpenSSL | 3.6.0+ | Required for ML-KEM, ML-DSA support |
| Qudo Provider | 1.0.0+ | FIPS-validated PQC provider |
| NGINX | 1.25+ (built with OpenSSL 3.6+) | For X25519MLKEM768 key exchange |
| Java | 17+ | For the Qudo JNI provider runtime |
| curl | 8.x (with OpenSSL 3.6+) | For testing PQC TLS from CLI |
| Chrome | 124+ | Hybrid PQC key exchange support |
| Firefox | 128+ | Hybrid PQC key exchange support |
| Safari | Not yet | Falls back to classical X25519 automatically |
| Python requests | Any (with system OpenSSL 3.6+) | TLS handled by underlying OpenSSL |
| Node.js | 18+ (with system OpenSSL 3.6+) | Uses system TLS for HTTPS |
X25519MLKEM768:X25519:P-384). Clients that don't support PQC key exchange automatically negotiate classical X25519. No client breaks.
openssl pkeyutl -sign fails with "input data length too long"Cause: ML-DSA requires -rawin flag for data larger than 64 bytes.
Fix: Always use openssl pkeyutl -sign -rawin -inkey key.pem -in data.bin -out sig.bin
Cause: ML-DSA-65 JWT is ~4.5KB. Default NGINX large_client_header_buffers is 8KB total.
Fix: Add large_client_header_buffers 4 16k; to your NGINX config.
openssl list -providers shows only "default"Cause: The FIPS module config isn't loaded, or the provider can't find libqudo-pqc.
Fix: Ensure openssl.cnf includes .include qudofipsmodule.cnf and the provider section
references qudo_sect. Run qudo_fipsinstall to regenerate the MAC hash if the dylib was rebuilt.
Cause: The browser doesn't support X25519MLKEM768, or it's disabled by policy.
How to check: Chrome DevTools > Security tab > "Key exchange group".
If it shows "X25519" instead of "X25519MLKEM768", the browser fell back to classical.
Chrome 124+ supports it. Safari doesn't yet and will always fall back.
Transport rollback: Change NGINX ssl_ecdh_curve back to X25519:P-384 — removes PQC, keeps TLS 1.3.
JWT rollback: Switch provider.sign() back to HMAC-SHA256 and update the alg header.
Each layer is independent. You can roll back transport without affecting JWT, and vice versa.
A: No. The dashboard's auto-fetch uses hard-coded admin/admin
credentials so the interactive "Execute" buttons work with one click. A production client must not do this.
Instead:
1. Authenticate the user through your real identity provider (OIDC, SAML, your own login form)
2. Call POST /api/auth/token with the user's real credentials
3. Store the returned JWT client-side and attach it to each request as Authorization: Bearer <token>
4. Re-authenticate when the JWT expires (default 1 hour), or use a refresh-token flow
The JWT signing / verification logic (ML-DSA-65 via Qudo provider) is what you migrate. The
login UX and credential handling are yours to design. See Section 3 for the Qudo JNI code.
A: No. If your client supports TLS 1.3, the hybrid PQC key exchange happens automatically at the TLS layer. Your HTTP code stays the same.
A: Yes. ML-DSA-65 signatures are ~3.3KB vs ~64 bytes for ES256. Total JWT is ~4-5KB. This is a known tradeoff of lattice-based signatures. Use the token in headers (not cookies) and increase NGINX header buffers.
A: Two options:
1. With Qudo provider (recommended): provider.verify(data, sig, pubKey, "ML-DSA-65") — runs locally, no network call
2. Without Qudo provider: Call your token verification endpoint (like this playground's /api/auth/verify) and let the server verify using Qudo
A: The NGINX config has fallbacks: X25519MLKEM768:X25519:P-384.
Clients without PQC support automatically negotiate classical X25519. No client breaks.
Chrome 124+ and Firefox 128+ support hybrid PQC natively.
A: Yes. Transport layer negotiates the best available key exchange — classical clients still work. JWT consumers need to support ML-DSA-65 verification (via Qudo provider or your verify endpoint). You can migrate transport and JWT independently.
A: Trust the CA certificate: curl --cacert certs/nginx/ca.crt https://localhost:8443/api/hello.
Or use -k / verify=False for testing (not production).
Analyze any endpoint's cryptographic configuration. Enter a host:port to scan its TLS setup and identify what's quantum-safe vs what needs migration.