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.

1
2
3
4
5
Visitor → [TLS] → Cloudflare Edge ←──────────────────── [mTLS, ML-DSA-44] ──→ NGINX Origin
                                   │                                            │
                    COTS: CF verifies origin cert          AOP: origin verifies CF client cert
                    (cots-server.crt signed by cots-ca)   (aop-client.crt signed by aop-ca)
                    Classical origin certs: rejected       Classical client certs: rejected

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.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
# Build dependencies
sudo apt-get install -y build-essential perl libssl-dev

# Download to /opt — survives reboots, unlike /tmp
sudo mkdir -p /opt/openssl-build
wget -q https://www.openssl.org/source/openssl-3.5.0.tar.gz \
  -O /opt/openssl-build/openssl-3.5.0.tar.gz
cd /opt/openssl-build && tar xf openssl-3.5.0.tar.gz

# Configure and build
cd /opt/openssl-build/openssl-3.5.0
./Configure --prefix=/usr/local/openssl350 --openssldir=/usr/local/openssl350
make -j2    # ~10 min on a 2-core VM
sudo make install

Important: always set LD_LIBRARY_PATH when calling this OpenSSL binary, otherwise it picks up the system libraries and quietly uses 3.0.x:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
LD_LIBRARY_PATH=/usr/local/openssl350/lib64 \
  /usr/local/openssl350/bin/openssl version
# OpenSSL 3.5.0 8 Apr 2025

# Quick sanity check — ML-DSA-44 key generation
LD_LIBRARY_PATH=/usr/local/openssl350/lib64 \
  /usr/local/openssl350/bin/openssl genpkey \
    -algorithm mldsa44 \
    -provparam ml-dsa.output_formats=seed-only \
    -out /tmp/test.key && echo "ML-DSA-44 OK"

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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
sudo bash -c '
set -e
export LD_LIBRARY_PATH=/usr/local/openssl350/lib64
OPENSSL=/usr/local/openssl350/bin/openssl
CERTDIR=/etc/nginx/ssl/pq

mkdir -p $CERTDIR

# 1. AOP CA — stays on the origin, goes into NGINX ssl_client_certificate
$OPENSSL genpkey -algorithm mldsa44 \
  -provparam ml-dsa.output_formats=seed-only \
  -out $CERTDIR/aop-ca.key

$OPENSSL req -new -x509 \
  -key $CERTDIR/aop-ca.key \
  -out $CERTDIR/aop-ca.crt \
  -days 10950 \
  -subj "/CN=AOP-CA"

# 2. AOP leaf — uploaded to Cloudflare; what CF presents to the origin
$OPENSSL genpkey -algorithm mldsa44 \
  -provparam ml-dsa.output_formats=seed-only \
  -out $CERTDIR/aop-client.key

$OPENSSL req -new \
  -key $CERTDIR/aop-client.key \
  -out $CERTDIR/aop-client.csr \
  -subj "/CN=cloudflare-aop-client" \
  -addext "basicConstraints=CA:FALSE" \
  -addext "keyUsage=digitalSignature" \
  -addext "subjectAltName=DNS:cloudflare-aop-client"

$OPENSSL x509 -req \
  -in $CERTDIR/aop-client.csr \
  -CA $CERTDIR/aop-ca.crt \
  -CAkey $CERTDIR/aop-ca.key \
  -CAcreateserial \
  -out $CERTDIR/aop-client.crt \
  -days 5475 \
  -copy_extensions copy

ls -lh $CERTDIR/
'
FileDestinationPurpose
aop-ca.keyOrigin only — never leavesCA private key
aop-ca.crtOrigin (ssl_client_certificate)Trust anchor for NGINX
aop-client.keyUploaded to CloudflareLeaf private key
aop-client.crtUploaded to CloudflareWhat 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.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
ZONE_ID="<your-zone-id>"
HOSTNAME="<your-hostname>"
CF_TOKEN="<your-api-token>"

CERT=$(jq -Rs . < aop-client.crt)
KEY=$(jq -Rs . < aop-client.key)

# Upload the ML-DSA client cert
CERT_RESPONSE=$(curl -s -X POST \
  "https://api.cloudflare.com/client/v4/zones/${ZONE_ID}/origin_tls_client_auth/hostnames/certificates" \
  -H "Authorization: Bearer ${CF_TOKEN}" \
  -H "Content-Type: application/json" \
  --data "{\"certificate\": ${CERT}, \"private_key\": ${KEY}}")

CERT_ID=$(echo "$CERT_RESPONSE" | jq -r '.result.id')
echo "Cert ID: $CERT_ID"

# Assign to hostname and enable
curl -s -X PUT \
  "https://api.cloudflare.com/client/v4/zones/${ZONE_ID}/origin_tls_client_auth/hostnames" \
  -H "Authorization: Bearer ${CF_TOKEN}" \
  -H "Content-Type: application/json" \
  --data "{\"config\": [{\"hostname\": \"${HOSTNAME}\", \"cert_id\": \"${CERT_ID}\", \"enabled\": true}]}" \
  | jq '.result[] | {hostname, status}'

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:

1
ssl_conf_command ClientSignatureAlgorithms MLDSA44:MLDSA65:MLDSA87;

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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
cd /opt/nginx-build/nginx-1.26.3
./configure \
  --prefix=/usr/local/nginx-pq \
  --with-http_ssl_module \
  --with-http_v2_module \
  --with-http_realip_module \
  --with-threads \
  --with-cc-opt='-I/usr/local/openssl350/include' \
  --with-ld-opt='-L/usr/local/openssl350/lib64 -Wl,-rpath,/usr/local/openssl350/lib64'

make -j2 && sudo make install

Verify the binary links against the right library — this is the ground truth:

1
2
3
4
5
6
ldd /usr/local/nginx-pq/sbin/nginx | grep -E 'ssl|crypto'
# libssl.so.3    => /usr/local/openssl350/lib64/libssl.so.3
# libcrypto.so.3 => /usr/local/openssl350/lib64/libcrypto.so.3

/usr/local/nginx-pq/sbin/nginx -V 2>&1 | grep -i openssl
# built with OpenSSL 3.5.0 8 Apr 2025

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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
server {
    listen 443 ssl;

    # Trust only the custom ML-DSA CA
    ssl_client_certificate /etc/nginx/ssl/pq/aop-ca.crt;

    # Enforce — no cert, no access
    ssl_verify_client on;

    # TLS 1.3 only — ML-DSA signatures don't work in TLS 1.2
    ssl_protocols TLSv1.3;

    # Accept only ML-DSA signature algorithms — reject RSA/ECDSA entirely
    ssl_conf_command ClientSignatureAlgorithms MLDSA44:MLDSA65:MLDSA87;

    # ... rest of your server config
}

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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
sudo bash -c '
set -e
export LD_LIBRARY_PATH=/usr/local/openssl350/lib64
OPENSSL=/usr/local/openssl350/bin/openssl
CERTDIR=/etc/nginx/ssl/pq

# 1. COTS CA — uploaded to Cloudflare; stays on origin too for signing
$OPENSSL genpkey -algorithm mldsa44 \
  -provparam ml-dsa.output_formats=seed-only \
  -out $CERTDIR/cots-ca.key

$OPENSSL req -new -x509 \
  -key $CERTDIR/cots-ca.key \
  -out $CERTDIR/cots-ca.crt \
  -days 10950 \
  -subj "/CN=COTS-CA"

# 2. COTS server leaf — presented by NGINX; CN must match your hostname
$OPENSSL genpkey -algorithm mldsa44 \
  -provparam ml-dsa.output_formats=seed-only \
  -out $CERTDIR/cots-server.key

$OPENSSL req -new \
  -key $CERTDIR/cots-server.key \
  -out $CERTDIR/cots-server.csr \
  -subj "/CN=<your-hostname>" \
  -addext "basicConstraints=CA:FALSE" \
  -addext "keyUsage=digitalSignature" \
  -addext "subjectAltName=DNS:<your-hostname>"

$OPENSSL x509 -req \
  -in $CERTDIR/cots-server.csr \
  -CA $CERTDIR/cots-ca.crt \
  -CAkey $CERTDIR/cots-ca.key \
  -CAcreateserial \
  -out $CERTDIR/cots-server.crt \
  -days 5475 \
  -copy_extensions copy

echo "Done:"
ls -lh $CERTDIR/cots-*
'
FileDestinationPurpose
cots-ca.keyOrigin only — never leavesCA private key
cots-ca.crtUploaded to CloudflareTrust anchor — replaces public CA store
cots-server.keyOrigin (ssl_certificate_key)Server private key
cots-server.crtOrigin (ssl_certificate)What NGINX presents to Cloudflare

Step 2 — Upload the COTS CA to Cloudflare

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
ZONE_ID="<your-zone-id>"
CF_TOKEN="<your-api-token>"   # Zone > SSL and Certificates > Edit

CA_CERT=$(jq -Rs . < cots-ca.crt)

curl -s -X POST \
  "https://api.cloudflare.com/client/v4/zones/${ZONE_ID}/acm/custom_trust_store" \
  -H "Authorization: Bearer ${CF_TOKEN}" \
  -H "Content-Type: application/json" \
  --data "{\"certificate\": ${CA_CERT}}" | jq '{id: .result.id, status: .result.status}'

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:

1
2
3
4
5
curl -s -X PATCH \
  "https://api.cloudflare.com/client/v4/zones/${ZONE_ID}/settings/ssl" \
  -H "Authorization: Bearer ${CF_TOKEN}" \
  -H "Content-Type: application/json" \
  --data '{"value": "strict"}' | jq '.result | {id, value}'

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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
server {
    listen 443 ssl;

    # COTS — origin presents ML-DSA-44 server certificate
    ssl_certificate     /etc/nginx/ssl/pq/cots-server.crt;
    ssl_certificate_key /etc/nginx/ssl/pq/cots-server.key;

    # AOP — origin verifies Cloudflare's ML-DSA-44 client certificate
    ssl_client_certificate /etc/nginx/ssl/pq/aop-ca.crt;
    ssl_verify_client      on;

    ssl_protocols TLSv1.3;
    ssl_conf_command ClientSignatureAlgorithms MLDSA44:MLDSA65:MLDSA87;

    # ... rest of your server config
}

Reload NGINX and verify the config loads cleanly:

1
sudo /usr/local/nginx-pq/sbin/nginx -t && sudo systemctl reload nginx-pq

Step 4 — Verify

Verify the server certificate directly from the origin (before Cloudflare is in the path):

1
2
3
4
5
6
7
8
9
# Run from the origin itself — connect to localhost
LD_LIBRARY_PATH=/usr/local/openssl350/lib64 \
  /usr/local/openssl350/bin/openssl s_client \
    -connect localhost:443 \
    -CAfile /etc/nginx/ssl/pq/cots-ca.crt \
    -brief 2>&1 | grep -E 'Signature type|Verification|Negotiated'
# Signature type: mldsa44
# Verification: OK
# Negotiated group: X25519MLKEM768

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:

1
2
3
4
5
6
7
8
curl -s https://<your-hostname>/headers | python3 -c "
import sys, json
h = json.load(sys.stdin)['headers']
print('X-Client-Verify:', h.get('X-Client-Verify'))
print('X-Client-S-Dn:  ', h.get('X-Client-S-Dn'))
"
# X-Client-Verify: SUCCESS
# X-Client-S-Dn:   CN=cloudflare-aop-client

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.

ControlStatusNote
ML-DSA CA only in ssl_client_certificateClassical AOP CA replaced
NGINX rejects classical client cert signaturesClientSignatureAlgorithms MLDSA44:MLDSA65:MLDSA87
TLS 1.3 onlyssl_protocols TLSv1.3
Zone-level AOP disabled on Cloudflare sideVia 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:

1
2
3
4
5
6
7
8
curl -s https://<your-hostname>/headers | python3 -c "
import sys, json
h = json.load(sys.stdin)['headers']
print('X-Client-Verify:', h.get('X-Client-Verify'))
print('X-Client-S-Dn:  ', h.get('X-Client-S-Dn'))
"
# X-Client-Verify: SUCCESS
# X-Client-S-Dn:   CN=cloudflare-aop-client

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:

1
2
3
# Direct connection without a client cert — should fail
openssl s_client -connect <origin-ip>:443 -brief
# SSL alert: certificate required — connection 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