TLS is the layer most developers never touch. You configure a certificate, point a load balancer at it, and trust that the encryption works. That trust is well-founded today. It won't be forever.
The threat isn't theoretical. Nation-state actors are already capturing encrypted traffic now — TLS handshakes, API calls, everything — to decrypt once quantum computers are powerful enough to run Shor's algorithm at scale. NIST's own timeline puts that window inside the next 10 to 15 years. For most data, that's fine. For anything with long-term sensitivity — financial records, health data, credentials, inter-service communication — the handshake you're doing today may be the vulnerability that matters in 2035.
NIST finalized X25519MLKEM768 for TLS 1.3 in 2024. It's a hybrid key exchange: classical X25519 combined with ML-KEM-768 (CRYSTALS-Kyber), so connections are protected against both classical and quantum adversaries simultaneously. The standard exists. Implementations exist. What was missing was a way to deploy it without touching application code.
That's what I built.
The architecture problem
The obvious approach to adding post-quantum TLS to an existing backend is to recompile with a post-quantum-capable TLS library. That works if you control the backend, have the source, and can afford the integration work across every service. For most systems — legacy services, third-party APIs, microservices with different owners — it's not realistic.
The alternative is a terminating proxy. The proxy handles the post-quantum TLS handshake with the client. The backend receives a normal HTTPS connection. Nothing in the backend changes.
This is a well-understood pattern for classical TLS — nginx, Caddy, and HAProxy all do it. The gap was that none of them support X25519MLKEM768 at the handshake level. They terminate classical TLS and re-encrypt classically. The post-quantum protection exists only between the proxy and the backend, not between the client and the proxy — which is exactly the layer that matters for harvest-now-decrypt-later attacks.
To get genuine post-quantum protection, the client needs to negotiate X25519MLKEM768 directly with the proxy. That requires TLS termination at a proxy that actually implements the algorithm.
Why Rust and rustls
The implementation uses Rust with rustls and aws-lc-rs. The combination matters.
rustls with aws-lc-rs supports X25519MLKEM768 as a key exchange group — it's available today, in a production-grade library, without patching anything. Browsers including Chrome and Firefox already support X25519MLKEM768 on the client side. The negotiation works end-to-end with real clients.
Rust's memory safety guarantees matter here too. A proxy handling TLS is exactly the kind of code where buffer overflows and use-after-free bugs have historically been catastrophic. Rust eliminates that class of vulnerability at the type system level.
The proxy-core binary is intentionally minimal — accept TLS connections, extract the SNI hostname, route to the correct backend, forward traffic bidirectionally. No HTTP parsing, no middleware, no request modification beyond injecting x-forwarded-for and x-real-ip headers at the TCP level via PROXY Protocol.
TCP pass-through vs. TLS termination
The critical design decision was whether to do TLS pass-through (forward the encrypted bytes to the backend) or TLS termination (decrypt at the proxy, re-encrypt to the backend).
Pass-through preserves the original TLS session end-to-end — the backend terminates TLS directly. This sounds appealing but it means the backend has to support X25519MLKEM768, which defeats the purpose. The whole point is to add post-quantum protection without changing the backend.
Termination at the proxy is the right choice. The client negotiates X25519MLKEM768 with the proxy. The proxy decrypts, then re-encrypts to the backend with standard TLS 1.3. The backend sees a normal HTTPS connection.
The trade-off is that the proxy-to-backend segment uses classical TLS. For the harvest-now-decrypt-later threat model, this is acceptable — the traffic being captured at scale is the client-to-proxy segment, which is what's quantum-protected.
Multi-tenant routing
The proxy is multi-tenant. Multiple customers each have their own domains, backends, and certificates, all handled by a single proxy-core instance.
Routing uses TLS SNI (Server Name Indication) — the hostname the client includes in the TLS ClientHello before the handshake completes. The proxy reads the SNI, looks up the domain configuration from a shared in-memory router, and connects to the appropriate backend.
The router refreshes every 30 seconds from the management API. When a customer adds or deletes a domain, it's live in the proxy within half a minute — no restart, no reload.
Per-domain certificates come from Let's Encrypt via ACME HTTP-01 challenge. The proxy obtains and renews certificates automatically. Customers can also upload their own certificates (BYOC — bring your own certificate) for domains where they already have a cert or need a specific CA.
One thing that required careful implementation: the ACME HTTP-01 challenge happens over port 80, before a certificate exists. The proxy has to handle the challenge response before it can serve TLS on port 443. This means keeping a separate HTTP listener for ACME only, routing /.well-known/acme-challenge/ requests to the ACME handler and everything else to an HTTPS redirect.
Real IP forwarding
When a proxy terminates TLS, the backend sees the proxy's IP address, not the client's. For most backends this breaks rate limiting, geo-filtering, audit logging, and anything else that depends on knowing who's connecting.
The standard fix is x-forwarded-for and x-real-ip headers. But these are HTTP headers, and the proxy operates at the TCP level — it doesn't parse HTTP. The solution is PROXY Protocol v1, a lightweight plaintext prefix injected at the start of the TCP stream that carries the original client IP. Backends that support PROXY Protocol (nginx, most cloud load balancers) parse it transparently.
The proxy injects PROXY Protocol on the backend connection if the domain is configured for it. The client sees nothing different.
The management plane
The proxy-core is stateless at the routing level — it pulls domain configuration from the management API and caches it in memory. The management API is a separate Axum service handling everything else: authentication, domain configuration, certificate storage, billing, metrics, health monitoring.
Separation matters here. The proxy-core is the performance-critical path — it handles real traffic, and any latency it adds to the TLS handshake is latency the client experiences. The management API handles lower-frequency operations. Running them as separate services means a management API deployment doesn't interrupt proxy traffic.
Communication between proxy-core and management-api uses an internal network port on Fly.io, not exposed publicly. The management API's public-facing port handles the dashboard and API key endpoints only.
What the handshake looks like
When a client connects to a domain behind PQ-Proxy, the TLS handshake negotiates X25519MLKEM768 if the client supports it, or falls back to X25519 for clients that don't. The handshake is measurably heavier than classical TLS — ML-KEM-768 key encapsulation involves larger key sizes and more computation than pure X25519.
In production on Fly.io GRU (São Paulo), average TLS handshake times run around 120–300ms for connections from South America. From further away the numbers are higher — X25519MLKEM768 adds some overhead, but the dominant factor is still network latency. Worth noting: the TLS handshake happens once per connection, not per request. With HTTP/2 and keep-alive, the cost amortizes quickly across multiple requests on the same connection.
The current deployment runs on Fly.io GRU. The architecture supports multi-region expansion — adding a region is a single fly scale command, with anycast routing directing connections to the nearest instance automatically. North America and Europe are the next regions on the roadmap as demand grows.
The connection log shows per-connection handshake times alongside backend connect times, so it's easy to see the breakdown for any specific connection.
PQ-Proxy is the second piece of the FIPSign suite
I built PQ-Sign earlier this year — a post-quantum signing API on ML-DSA-65 (NIST FIPS 204) for signing tokens, issuing certificates, and verifying identity. ML-DSA-65 handles the what you sign problem.
PQ-Proxy handles the how you transmit it problem. Together they cover the two layers where quantum attacks are most relevant: signatures and transport.
The suite is at fipsign.dev. PQ-Proxy has a 7-day free trial — add a domain, update the A record, and your backend has post-quantum TLS without touching a line of application code.
Top comments (0)