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.
| Dashboard | URL | What lives here |
|---|---|---|
| Main Cloudflare | dash.cloudflare.com | DNS, SSL/TLS, Client Certificates, WAF |
| Zero Trust | one.dash.cloudflare.com | WARP, 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)
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
- Open the Client Certificates section under SSL/TLS
- Click Edit next to "Hosts"
- Add the hostname that will require mTLS (for example,
service.yourdomain.com) - 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
- Click Create Certificate
- Choose RSA or ECDSA (either works; ECDSA produces smaller keys, RSA has broader legacy support)
- 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
- 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.
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:
- Press
Win + R, typecertmgr.msc, and press Enter - Navigate to Personal > Certificates
- Right-click > All Tasks > Import
- Select the
.pfxfile 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).
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
- Click Add an Application > Self-hosted
- Set the Application domain to the same subdomain:
service.yourdomain.com - Give the application a name
- Click Next
3.2 Create an Access Policy
- Set Policy name to something descriptive
- Set Action to Allow
- Under Include, select Emails and enter the specific email addresses that should be allowed
- Click Save policy
- 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 Existcf.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
| Symptom | Likely cause |
|---|---|
| Cert holders are blocked | mTLS 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 1 | WAF custom rule order is wrong (drag the allow rule above the deny rule in the Custom Rules list) |
cf.access.authenticated causes parse error | Not a valid WAF field (use len(http.request.headers["cf-access-jwt-assertion"][0]) gt 0 instead) |
| Can't find Client Certificates | You 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 rule | Access 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 error | Chain 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 browser | Certificate 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
- Cloudflare Docs: Mutual TLS
- Cloudflare Docs: Client Certificates
- Cloudflare Docs: WAF Custom Rules
- Cloudflare Docs: Access Applications
- Cloudflare Docs: Cloudflare Tunnel
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.