Implementing mTLS end-to-end
Two independent TLS legs, each with mutual authentication: the client proves itself to Cloudflare, and Cloudflare proves itself to your origin.
client cert → Cloudflare
CF cert → origin (AOP)
Leg 2 — Cloudflare → Origin (Authenticated Origin Pulls): Cloudflare presents a client certificate that your origin verifies, so the origin only accepts traffic from Cloudflare.
0. Prerequisites
- A zone on Cloudflare (e.g.
pimenta.fun) with the hostname proxied (orange cloud). opensslavailable locally to mint a CA and certificates.- Access to the origin's web-server config (Apache here) and Cloudflare dashboard / API token.
- SSL/TLS mode at least Full; Full (Strict) recommended.
Part 1 — Client → Cloudflare (client certificate auth)
Cloudflare challenges the client for a certificate during the TLS handshake and verifies it against a CA you register (API Shield → mTLS).
Create a CA & client certificate CLI
Generate your own CA, then issue a client cert signed by it:
# 1a. Root CA openssl genrsa -out ca.key 4096 openssl req -x509 -new -nodes -key ca.key -sha256 -days 3650 \ -subj "/CN=Pimenta Lab Client CA" -out ca.pem # 1b. Client key + CSR openssl genrsa -out client.key 2048 openssl req -new -key client.key -subj "/CN=lab-client" -out client.csr # 1c. Sign the client cert with the CA openssl x509 -req -in client.csr -CA ca.pem -CAkey ca.key \ -CAcreateserial -days 365 -sha256 -out client.crt # 1d. (optional) bundle for browsers openssl pkcs12 -export -inkey client.key -in client.crt \ -certfile ca.pem -out client.p12
Register the CA with Cloudflare Dashboard API
Dashboard: SSL/TLS → Client Certificates (or Security → API Shield → mTLS) →
Add mTLS certificate → paste the contents of ca.pem.
Or via API (upload the CA to the mTLS certificate store):
curl -X POST \
"https://api.cloudflare.com/client/v4/zones/$ZONE_ID/certificate_authorities/hostname_associations" \
-H "Authorization: Bearer $CF_TOKEN" -H "Content-Type: application/json" \
--data '{"certificates":[""]}'
Associate the hostname & require mTLS Dashboard
Enable mTLS for the hostname (e.g. mtls.pimenta.fun), then enforce it with a
WAF custom rule so unverified clients are blocked:
# Expression (Security -> WAF -> Custom rules) (http.host eq "mtls.pimenta.fun" and not cf.tls_client_auth.cert_verified) # Action: Block (or "Skip" the rule to allow only when verified)
cf.tls_client_auth.cert_verified,
cf.tls_client_auth.cert_presented,
cf.tls_client_auth.cert_subject_dn.Test the client leg CLI
# Without a cert -> blocked (403) curl -i https://mtls.pimenta.fun/ # With the client cert -> allowed curl -i --cert client.crt --key client.key https://mtls.pimenta.fun/
Browsers: import client.p12 into the OS/keychain; the browser prompts to send it.
Part 2 — Cloudflare → Origin (Authenticated Origin Pulls)
Authenticated Origin Pulls (AOP) makes Cloudflare present a client certificate to your origin. Your origin verifies it, so it accepts traffic only from Cloudflare.
Choose a mode & enable AOP Dashboard
- Zone-level: SSL/TLS → Origin Server → Authenticated Origin Pulls → On. Cloudflare presents its shared origin-pull certificate; your origin trusts Cloudflare's origin-pull CA.
- Per-hostname: upload your own client cert/key via API and bind it to the hostname — stronger isolation per zone.
Get the CA the origin should trust CLI
For zone-level AOP, download Cloudflare's origin-pull CA and place it on the origin:
sudo mkdir -p /etc/apache2/cloudflare sudo curl -fsSL \ https://developers.cloudflare.com/ssl/static/authenticated_origin_pull_ca.pem \ -o /etc/apache2/cloudflare/origin-pull-ca.pem
For per-hostname AOP, trust the CA that signed the cert you uploaded instead.
Require & verify the client cert on the origin (Apache)
Add to your origin's :443 vhost so Apache demands Cloudflare's certificate:
<VirtualHost *:443>
ServerName mtls.pimenta.fun
SSLEngine on
SSLCertificateFile /etc/letsencrypt/live/mtls.pimenta.fun/fullchain.pem
SSLCertificateKeyFile /etc/letsencrypt/live/mtls.pimenta.fun/privkey.pem
# --- Authenticated Origin Pulls ---
SSLVerifyClient require
SSLVerifyDepth 1
SSLCACertificateFile /etc/apache2/cloudflare/origin-pull-ca.pem
</VirtualHost>
nginx equivalent: ssl_client_certificate + ssl_verify_client on;
Reload & lock down CLI
sudo apache2ctl configtest && sudo systemctl reload apache2
Optionally restrict the origin firewall to Cloudflare IP ranges so direct (non-CF) traffic can't reach it.
3. Set the SSL/TLS encryption mode
Use Full (Strict) (SSL/TLS → Overview) so Cloudflare validates the origin certificate as well. Combined with AOP you get verified TLS in both directions on Leg 2.
4. Verify end-to-end
# Leg 1: client must present a verified cert curl -i --cert client.crt --key client.key https://mtls.pimenta.fun/ # Leg 2: hitting the origin directly (bypassing CF) should now FAIL, # because the origin requires Cloudflare's client certificate: curl -i --resolve mtls.pimenta.fun:443:<ORIGIN_IP> https://mtls.pimenta.fun/ # -> expect: 400 No required SSL certificate was sent
The lab's mTLS test page also surfaces the
cf-client-cert-* headers when Leg 1 is configured.
5. Troubleshooting
- Browser never prompts for a cert (Leg 1): the hostname isn't associated for mTLS, or the cert isn't imported into the OS store.
- Everyone gets through (Leg 1): you registered the CA but didn't add the enforcing WAF rule (
not cf.tls_client_auth.cert_verified). - 525/526 errors (Leg 2): origin cert invalid for Full (Strict), or AOP CA mismatch.
- 400 "No required SSL certificate" for normal visitors (Leg 2): AOP is required on the origin but not enabled in Cloudflare, so CF isn't sending its cert.
- Direct-to-origin still works: add a Cloudflare-IP allowlist at the origin firewall.