Skip to main content

Securing a Self-Hosted Service with Cloudflare Zero Trust and mTLS

Putting a self-hosted service behind Cloudflare's proxy gives you DDoS protection and hides your origin IP, but by default the hostname is still publicly reachable by anyone on the internet. Mutual TLS (mTLS) fixes that at the edge: only devices holding a certificate you issued can establish a connection. Everything else hits a block page before a single byte reaches your origin.

This guide walks through the complete configuration: issuing client certificates from Cloudflare's managed CA, writing the WAF rules that enforce them, and optionally adding a Cloudflare Zero Trust Access path for devices that don't carry a cert. The example service is a home automation dashboard, but the steps apply to any subdomain you proxy through Cloudflare.


The Two-Dashboard Problem

Before touching any settings, understand this: there are two completely separate Cloudflare dashboards with different URLs. Almost every mTLS setup I've seen go wrong can be traced back to someone configuring things in the wrong one.

DashboardURLWhat lives here
Main Cloudflaredash.cloudflare.comDNS, SSL/TLS, Client Certificates, WAF
Zero Trustone.dash.cloudflare.comWARP, Access Applications, Access Policies

Client certificate management lives in the main dashboard under the domain's SSL/TLS section (not in the Zero Trust dashboard). The Zero Trust dashboard has no awareness of client certificates; it handles identity-based access through WARP and Access policies. This guide calls out which dashboard to use at each step.


Prerequisites

Before you start, make sure you have:

  • A domain registered and proxied through Cloudflare (orange-cloud DNS on the record)
  • A self-hosted service accessible at a subdomain (e.g., service.yourdomain.com) (ideally via a Cloudflare Tunnel so no origin ports are exposed to the internet)
  • A Cloudflare account (the free plan supports client certificates and WAF custom rules)
  • OpenSSL installed locally (for certificate format conversion on Windows)
Time Estimate

Initial setup takes about 15-20 minutes for a single subdomain. Add 10 minutes if you configure the Zero Trust Access path.


Step 1: Enable mTLS and Issue Client Certificates

Dashboard: Main Cloudflare (dash.cloudflare.com) > select your domain > SSL/TLS > Client Certificates

1.1 Enable mTLS for the Hostname

  1. Open the Client Certificates section under SSL/TLS
  2. Click Edit next to "Hosts"
  3. Add the hostname that will require mTLS (for example, service.yourdomain.com)
  4. Save

This step registers the hostname for mTLS enforcement. Skipping it means cf.tls_client_auth.cert_verified always evaluates to false, even when clients present a valid certificate. That will block everyone once you add the WAF rules in Step 2.

1.2 Create a Client Certificate

  1. Click Create Certificate
  2. Choose RSA or ECDSA (either works; ECDSA produces smaller keys, RSA has broader legacy support)
  3. Set the validity period. I use 10 years for personal and homelab use to avoid maintenance overhead. For a team environment, shorter validity with a rotation schedule is better practice
  4. Click Create

Cloudflare generates the certificate and private key pair. Download both the Certificate (.pem) and the Private Key (.key) immediately (the private key is shown only once and cannot be retrieved after you leave the page). Store both files somewhere secure.

One-Time Download

The private key is only available at creation time. If you navigate away without saving it, you must create a new certificate. There is no way to recover it.

1.3 What These Certificates Are (and Are Not)

Certificates issued here are signed by a Cloudflare-managed, account-level root CA. They are only valid for mTLS validation at the Cloudflare edge (the edge checks the certificate against its own CA when a request arrives).

These certificates are not trusted by any browser or operating system by default. They are not server TLS certificates. If you inspect one directly, it will show as "unknown issuer." That is expected behavior (they serve a different purpose than the server-side certificate that handles HTTPS).

1.4 Install the Certificate on Client Devices

The certificate needs to be installed on every device that should have access.

Windows:

Convert the certificate and key to .pfx format using OpenSSL:

openssl pkcs12 -export -out cert.pfx -inkey key.pem -in cert.pem

Import the .pfx via Certificate Manager:

  1. Press Win + R, type certmgr.msc, and press Enter
  2. Navigate to Personal > Certificates
  3. Right-click > All Tasks > Import
  4. Select the .pfx file and follow the import wizard

macOS:

Double-click the .pfx or .p12 file to open Keychain Access and import it. When prompted, mark it as trusted for this account.

iOS:

Create a .mobileconfig profile containing the certificate, or use Apple Configurator for managed deployments. Apple's own documentation covers both paths.

Android:

Navigate to Settings > Security > Install a certificate > VPN & app certificate and select the certificate file.


Step 2: Create the WAF Enforcement Rules

Dashboard: Main Cloudflare (dash.cloudflare.com) > select your domain > Security > WAF > Custom Rules

Two rules enforce mTLS: one that allows requests with a valid certificate, and one that blocks everything else. Rule ordering is enforced top-down, so the allow rule must be above the deny rule.

2.1 Rule 1: Allow Verified mTLS Clients

Click Create rule and configure:

Name: Allow mTLS clients
Expression: (cf.tls_client_auth.cert_verified and http.host eq "service.yourdomain.com")
Action: Skip > Skip all remaining custom rules

This rule matches requests that present a certificate verified against the Cloudflare CA for your account. Matching requests skip all subsequent custom rules (including the block rule below).

2.2 Rule 2: Default Deny

Click Create rule and configure:

Name: Default deny
Expression: (http.host eq "service.yourdomain.com")
Action: Block

This rule matches every request to the subdomain. Because it sits below Rule 1, it only fires when Rule 1 did not match (meaning the request did not present a valid certificate).

note

The allow-then-deny pattern is standard WAF practice. An explicit deny-all at the bottom is more reliable than relying on implicit fallthrough behavior, because Cloudflare's default action for unmatched requests is to allow them.

2.3 Why cf.tls_client_auth.cert_verified Requires Hostname Registration

This field is only populated when two conditions are true: the request includes a client certificate, AND mTLS has been enabled for that hostname in SSL/TLS > Client Certificates (Step 1.1). If you skipped the hostname registration step, the field always evaluates to false. Every request falls through to Rule 2 and gets blocked, including ones from legitimate cert holders.


Step 3: Add a Zero Trust Access Path (Optional)

Dashboard: Zero Trust (one.dash.cloudflare.com) > Access > Applications

Sometimes you need to grant access to a device without a client certificate (a family member who needs occasional access, a borrowed device, or a situation where installing a certificate is not practical). Cloudflare Access handles this through a separate authentication path: the user authenticates with an email OTP or identity provider via WARP, and Access injects a signed JWT header into the proxied request.

3.1 Create an Access Application

  1. Click Add an Application > Self-hosted
  2. Set the Application domain to the same subdomain: service.yourdomain.com
  3. Give the application a name
  4. Click Next

3.2 Create an Access Policy

  1. Set Policy name to something descriptive
  2. Set Action to Allow
  3. Under Include, select Emails and enter the specific email addresses that should be allowed
  4. Click Save policy
  5. Complete the application setup and click Save

3.3 Update Rule 1 to Recognize Access-Authenticated Traffic

Go back to the main dashboard WAF and update Rule 1's expression to also pass through requests that have already been validated by Access:

Expression:
(
cf.tls_client_auth.cert_verified or
len(http.request.headers["cf-access-jwt-assertion"][0]) gt 0
)
and http.host eq "service.yourdomain.com"

When a user authenticates through an Access policy, Cloudflare injects a cf-access-jwt-assertion header into the proxied request. Checking that this header is present and non-empty confirms the request passed Access authentication.

cf.access.authenticated Does Not Exist

cf.access.authenticated is not a valid field in Cloudflare WAF custom rules. It does not exist, and using it produces an expression parsing error when you try to save the rule.

This trips up a lot of people because the field name looks like it should exist and appears in some community posts and older documentation. The correct approach is checking for the cf-access-jwt-assertion header as shown above.


Step 4: Testing

Run through these tests in order after completing configuration.

4.1 Test the Certificate Path

On a device with the client certificate installed, open a browser and navigate to service.yourdomain.com. If prompted by the browser, select the client certificate. The service should load normally with no Access login prompt.

4.2 Test the Deny Path

Open an incognito window on a device with no client certificate installed and no WARP connection active. Navigate to service.yourdomain.com. You should receive a Cloudflare block page (HTTP 403 or a custom block page if you configured one). If the service loads instead, Rule 2 is not in place or rule ordering is wrong.

4.3 Test the Access Path (if configured)

Connect WARP and authenticate with one of the email addresses in the Access policy. Navigate to service.yourdomain.com. The service should load after Access authentication without requiring a client certificate.

4.4 Verify Rule Ordering

If certificate holders are getting blocked, check rule ordering first. In the WAF Custom Rules view, the allow rule must appear above the deny rule. Drag to reorder if needed. The allow rule should have a lower rule number than the deny rule.


Troubleshooting

SymptomLikely cause
Cert holders are blockedmTLS not enabled for this hostname in SSL/TLS > Client Certificates (cf.tls_client_auth.cert_verified is always false without hostname registration)
Rule 2 fires before Rule 1WAF custom rule order is wrong (drag the allow rule above the deny rule in the Custom Rules list)
cf.access.authenticated causes parse errorNot a valid WAF field (use len(http.request.headers["cf-access-jwt-assertion"][0]) gt 0 instead)
Can't find Client CertificatesYou are in the Zero Trust dashboard (navigate to the main dashboard at dash.cloudflare.com, select your domain, then SSL/TLS)
WARP users still blocked after adding Access ruleAccess Application domain does not match WAF rule hostname exactly (check for www prefix mismatches or trailing slash differences)
Browser shows certificate prompt then a 520 errorChain of trust issue (verify the certificate was created in the Cloudflare dashboard for this account, not generated externally or self-signed)
Certificate prompt does not appear in browserCertificate is not installed in the correct store (on Windows, it must be in Personal > Certificates in certmgr.msc, not in a user profile keystore)

Key Takeaways

✅ Client certificates and WAF rules live in the main Cloudflare dashboard (dash.cloudflare.com), not in Zero Trust

✅ Enable mTLS for the specific hostname in SSL/TLS > Client Certificates before writing WAF rules, or cf.tls_client_auth.cert_verified will always be false

✅ Download the private key at creation time (it cannot be retrieved later)

✅ Place the allow rule above the deny rule in WAF Custom Rules; ordering is enforced top-down

cf.access.authenticated does not exist as a WAF field (check for the cf-access-jwt-assertion header instead)

✅ The Access path (Zero Trust) and the mTLS path are independent (either one can be used to allow a request through)


Additional Resources


Once the two WAF rules are in place, your service is unreachable to anyone who does not hold a certificate you issued. No open ports, no brute-force surface, no login page exposed to the internet.