TL;DR — Cloudflare now supports ML-DSA-44 (FIPS 204) for both Authenticated Origin Pulls (AOP) and Custom Origin Trust Store (COTS). This post walks through building OpenSSL 3.5.0 from source, generating two separate post-quantum cert chains, and wiring up full bidirectional ML-DSA authentication between Cloudflare and an NGINX origin. The result: a connection where Cloudflare authenticates itself to the origin and the origin authenticates itself to Cloudflare — both with post-quantum signatures. Combined with X25519MLKEM768 key agreement, no classical cryptography is involved at any point.
The stat that matters
According to Cloudflare Radar , over 65% of human traffic to Cloudflare is now post-quantum encrypted. That’s a remarkable number — the result of years of steady work deploying X25519MLKEM768 key agreement by default across Cloudflare’s edge.
But encryption is only half the picture. A quantum computer doesn’t just decrypt intercepted traffic — it can forge credentials. It can impersonate your origin server, or impersonate Cloudflare to your origin. Once sufficiently capable quantum computers exist, any system that still relies on RSA or ECDSA for authentication is vulnerable to active impersonation attacks, not just passive decryption.
That’s why Cloudflare published a roadmap targeting 2029 for full post-quantum security , and why the first milestone on that roadmap — post-quantum authentication for Cloudflare-to-origin connections — shipped in mid-2026. The announcement blog post from Luke Valenta and Kevin Guthrie is excellent reading and the direct foundation for what follows here.
This post is the implementation side: what it actually takes to get full post-quantum mutual TLS working end-to-end — AOP so the origin can verify Cloudflare, COTS so Cloudflare can verify the origin — plus the non-obvious pitfall that will silently break your NGINX config, and how to close every downgrade gap.
The architecture
The mid-2026 update to Cloudflare’s SSL/TLS products added ML-DSA support to two complementary features:
- Authenticated Origin Pulls (AOP) — Cloudflare presents a client certificate to the origin on every connection. The origin verifies it: no valid ML-DSA cert, no access. Closes the “is this really Cloudflare?” question.
- Custom Origin Trust Store (COTS) — The origin presents a server certificate signed by a CA you control and upload to Cloudflare. Cloudflare trusts only that CA instead of the default public CA store. Closes the “is this really my origin?” question. Requires Advanced Certificate Manager .
Together they give you full bidirectional post-quantum mTLS on the Cloudflare-to-origin leg. The setup here uses per-hostname AOP paired with zone-level COTS, both scoped in effect to the target hostname.
| |
The result: neither side can be impersonated with a classical RSA or ECDSA credential — even by an adversary with a quantum computer.
Phase 1 — OpenSSL 3.5.0
Ubuntu 24.04 LTS ships OpenSSL 3.0.13. ML-DSA (FIPS 204) requires OpenSSL 3.5.0 or later. The installation goes side-by-side at /usr/local/openssl350 — the system OpenSSL is left completely untouched.
| |
Important: always set LD_LIBRARY_PATH when calling this OpenSSL binary, otherwise it picks up the system libraries and quietly uses 3.0.x:
| |
Phase 2 — Generate the ML-DSA-44 certificate chain
Two certificates are needed: a CA (trust anchor that stays on the origin) and a leaf client certificate (what Cloudflare will present on every connection). Run this as a single block on the origin:
| |
| File | Destination | Purpose |
|---|---|---|
aop-ca.key | Origin only — never leaves | CA private key |
aop-ca.crt | Origin (ssl_client_certificate) | Trust anchor for NGINX |
aop-client.key | Uploaded to Cloudflare | Leaf private key |
aop-client.crt | Uploaded to Cloudflare | What CF presents to origin |
The private key is generated in FIPS 204 seed-only format — the only format Cloudflare currently accepts on upload.
Phase 3 — Upload to Cloudflare
Per-hostname AOP uses the /origin_tls_client_auth/hostnames/certificates endpoint. You’ll need an API token with Zone > SSL and Certificates > Edit permission.
| |
Wait for status: "active" — it typically takes 30–60 seconds to propagate.
The critical gotcha — static vs. dynamic NGINX build
This is where most people get stuck, and the failure mode is particularly frustrating because NGINX will load the config without errors, but the behavior is silently wrong.
The goal is to restrict NGINX to only accept ML-DSA client certificate signatures — rejecting RSA and ECDSA entirely. NGINX exposes this via ssl_conf_command:
| |
The problem: this only works if NGINX is dynamically linked against OpenSSL 3.5.0.
The natural instinct is to build NGINX with --with-openssl=/opt/openssl-build/openssl-3.5.0, which statically embeds OpenSSL into the NGINX binary. With a static build, ML-DSA algorithm names are not registered in the SSL_CONF_cmd context at config-parse time. Every ML-DSA identifier fails — short names, OID names, numeric OIDs — all rejected with bad value. NGINX falls back to the full classical signature algorithm list, meaning it will accept RSA and ECDSA client certs. No error. No warning. Just a quietly broken configuration.
You can confirm the problem by running an openssl s_client against the origin and inspecting the Requested Signature Algorithms line in the TLS handshake. With a broken build it will show the full classical chain.
The fix: install OpenSSL 3.5.0 first (Phase 1), then build NGINX using --with-cc-opt and --with-ld-opt to link dynamically against the installed shared libraries:
| |
Verify the binary links against the right library — this is the ground truth:
The -Wl,-rpath flag embeds the library search path in the binary itself, so LD_LIBRARY_PATH doesn’t need to be set at runtime.
Phase 4 & 5 — NGINX config and enforcement
With the dynamic NGINX binary in place, the config is straightforward. The key directives:
| |
One note on the ClientSignatureAlgorithms format: with the dynamic build, the correct names are MLDSA44, MLDSA65, MLDSA87 — not the OID format (id-ml-dsa-44) that the static build will reject. Test both to confirm which your build accepts.
Phase 6 — Custom Origin Trust Store (COTS)
AOP covers one direction: Cloudflare authenticating itself to your origin. COTS covers the other: your origin authenticating itself to Cloudflare with a certificate it trusts. Without COTS, the origin still presents a classical RSA/ECDSA server certificate — a quantum adversary capable of impersonating your origin could still do so.
Prerequisites: COTS requires Advanced Certificate Manager to be enabled on the zone. Only root CAs are accepted (no intermediates). Critically, uploading a COTS CA replaces the default public CA trust store for the entire zone — Cloudflare will no longer trust any publicly-signed origin certificate on that zone. Make sure all other hostnames on the zone are either not proxied or are also switching to a custom CA before enabling this.
Step 1 — Generate the COTS CA and server certificate
Run on the origin — same OpenSSL 3.5.0 binary as before:
| |
| File | Destination | Purpose |
|---|---|---|
cots-ca.key | Origin only — never leaves | CA private key |
cots-ca.crt | Uploaded to Cloudflare | Trust anchor — replaces public CA store |
cots-server.key | Origin (ssl_certificate_key) | Server private key |
cots-server.crt | Origin (ssl_certificate) | What NGINX presents to Cloudflare |
Step 2 — Upload the COTS CA to Cloudflare
| |
Wait for status: "active". Once active, Cloudflare will only trust origin certificates signed by this CA on your zone — any origin still presenting a publicly-signed cert will get a 526.
Also ensure your SSL mode is set to Full (strict) — COTS is only enforced in strict mode:
Step 3 — Configure NGINX to present the ML-DSA server certificate
Add the COTS cert and key to the server block alongside the existing AOP directives:
| |
Reload NGINX and verify the config loads cleanly:
| |
Step 4 — Verify
Verify the server certificate directly from the origin (before Cloudflare is in the path):
| |
Verify end-to-end through Cloudflare — if X-Client-Verify: SUCCESS is still present and the site returns HTTP 200, Cloudflare successfully verified the ML-DSA server certificate and completed the ML-DSA client cert handshake:
At this point both directions are locked: Cloudflare can’t be impersonated to the origin, and the origin can’t be impersonated to Cloudflare.
Closing the downgrade gaps
Getting the ML-DSA cert chain in place is the foundation, but it’s not sufficient for full downgrade prevention. Three gaps were identified and closed:
Gap 1 — NGINX accepting classical signature algorithms (the one above)
Fixed by the dynamic NGINX build + ssl_conf_command ClientSignatureAlgorithms MLDSA44:MLDSA65:MLDSA87. Without this, NGINX advertises ML-DSA first but then lists the full classical fallback chain. A quantum adversary forging a classical credential would authenticate successfully.
Gap 2 — Zone-level AOP still active with Cloudflare’s classical cert
By default, zones have zone-level AOP enabled with Cloudflare’s shared RSA certificate. Per-hostname AOP overrides this correctly for the target hostname, but the zone-level classical path still exists on the Cloudflare side. In practice, NGINX would reject Cloudflare’s classical cert anyway (since it only trusts aop-ca.crt), causing a 525 rather than a successful auth — but the clean configuration is to disable zone-level AOP entirely via the dashboard (SSL/TLS → Origin Server → Authenticated Origin Pulls → Zone-level toggle OFF).
Gap 3 — TLS 1.2 still enabled
ML-DSA signatures only work in TLS 1.3. A connection forced to TLS 1.2 bypasses ML-DSA entirely and falls back to classical cipher suites. Fix: ssl_protocols TLSv1.3; — drop TLS 1.2 on the origin.
| Control | Status | Note |
|---|---|---|
ML-DSA CA only in ssl_client_certificate | ✅ | Classical AOP CA replaced |
| NGINX rejects classical client cert signatures | ✅ | ClientSignatureAlgorithms MLDSA44:MLDSA65:MLDSA87 |
| TLS 1.3 only | ✅ | ssl_protocols TLSv1.3 |
| Zone-level AOP disabled on Cloudflare side | ✅ | Via dashboard |
| Origin-side ML-DSA cert (COTS) | ✅ | COTS CA active — Cloudflare verifies ML-DSA server cert |
All five controls are now in place. The connection is fully post-quantum on both sides.
Verification (AOP-only)
After Phases 1–5, verify the AOP direction by hitting the proxied endpoint and inspecting what Cloudflare passes through as request headers. Cloudflare forwards X-Client-Verify and X-Client-S-Dn on every proxied request:
When X-Client-Verify is SUCCESS and X-Client-S-Dn shows CN=cloudflare-aop-client — your custom ML-DSA cert, not CN=origin-pull.cloudflare.net which is Cloudflare’s default classical cert — AOP is working. Confirm direct access (bypassing Cloudflare) is rejected:
Full COTS verification is covered in Phase 6 above.
Live demo
If you want to see what this looks like in practice, I built a small Cloudflare Worker that shows the PQ AOP status in real time: pq-demo.macharpe.com .
It displays the X-Client-Verify result, certificate DN, algorithm, expiry, the originating Cloudflare PoP, and a connection flow timeline — all pulled live on each page load from the actual mTLS handshake between Cloudflare and the origin.
Access: the demo is protected by Cloudflare Access with email OTP. You can log in with any @gmail.com or @protonmail.com address — enter your email, receive a PIN, done. Those are the only two domains allowed for now; if you want to try it with a different domain, just ping me and I’ll add it.
One caveat: the origin server runs on a GCP free-tier VM with a schedule to keep costs in check. It is offline on weekends (Saturday and Sunday) and shuts down at roughly 8pm Paris time on weekdays, coming back up at 6am. If you get an error outside those hours, that’s why — try again during the window.
The full picture
With all six phases complete, the Cloudflare-to-origin connection has no classical cryptography left in it:
- Key agreement: X25519MLKEM768 — deployed by default on all Cloudflare-to-origin connections
- Client authentication (AOP): ML-DSA-44 — Cloudflare presents a custom cert; NGINX verifies it and rejects everything classical
- Server authentication (COTS): ML-DSA-44 — NGINX presents a custom cert; Cloudflare verifies it against the uploaded CA and rejects publicly-signed classical certs
This is what Cloudflare’s mid-2026 milestone on the path to full post-quantum security by 2029 looks like in practice. The tooling is available today, it runs on standard hardware, and the main cost is a source build of OpenSSL 3.5.0, a recompile of NGINX with dynamic linking, and some careful API work. No exotic hardware, no quantum lab required.
The visitor-to-Cloudflare leg is a separate problem — it’s bound to the WebPKI timeline and the browser ecosystem, and Cloudflare is working on Merkle Tree Certificates (MTC) targeting 2027. But the origin leg, which is entirely under operator control, is now fully locked down.
References
- Post-quantum authentication to origins is now supported — Cloudflare Blog, Luke Valenta & Kevin Guthrie, July 2026
- Cloudflare targets 2029 for full post-quantum security — Cloudflare Blog, Bas Westerbaan, April 2026
- PQC to Origin — Developer Docs
- Authenticated Origin Pulls — Developer Docs
- Custom Origin Trust Store — Developer Docs
- Cloudflare Radar — Post-Quantum Encryption
