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. It also covers the limitations of this approach for native mobile apps and the recommended alternative for managed devices. 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, and mTLS settings exist in both of them. They are completely different systems with different plan requirements. Almost every mTLS setup I've seen go wrong can be traced back to someone configuring things in the wrong one, or finding the Zero Trust version and hitting a plan wall.

DashboardURLmTLS locationPlan required
Main Cloudflaredash.cloudflare.comSSL/TLS > Client CertificatesFree
Zero Trustone.dash.cloudflare.comAccess controls > Service credentials > Mutual TLSEnterprise only

The two systems work differently. The main dashboard approach uses Cloudflare's managed CA to issue certificates and enforces them via WAF custom rules. The Zero Trust approach uses a CA you bring yourself and enforces via Access policies. Both achieve mTLS at the edge, but they are not interchangeable and do not share configuration.

This guide covers the main dashboard approach only. It works on the free plan and is the right path for personal, homelab, and small team use. If you navigate to Access controls > Service credentials > Mutual TLS in the Zero Trust dashboard and find it locked or empty, that is expected. That section requires an Enterprise plan. 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 and 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, which 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 since 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 > Security Rules > 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.


Native App Limitation: mTLS Does Not Work for Most Mobile Apps

Read this before configuring access for mobile apps

The WAF-based mTLS approach described in this guide works reliably for browser-based access on both desktop and mobile. It does not work for most native mobile apps on Android or iOS.

Browsers (Chrome, Safari, Edge, Brave) integrate with the operating system's certificate store and handle the TLS client certificate handshake automatically. When a browser connects to a hostname with mTLS enforced, it prompts the user to select a certificate and presents it during the TLS negotiation.

Native apps do not do this automatically. Unless an app has explicitly implemented client certificate selection, which most third-party self-hosted apps have not, the app establishes its connection without presenting a certificate. The Cloudflare edge never sees a cert, cf.tls_client_auth.cert_verified evaluates to false, and the request is blocked by Rule 2. This behavior has been confirmed across multiple open-source app projects and reproduced consistently regardless of whether the certificate is installed in the system keystore, WARP is active, or a Zero Trust Access policy is in place.

The result is that a user whose browser works perfectly with the cert installed will find that the native app for the same service fails to connect with the same cert on the same device. This is not a configuration error. It is a fundamental limitation of how most native apps handle TLS on mobile platforms.

If you need to support native app access on devices you control, see Step 4: WARP Private Network Routing for Managed Devices below.


Step 3: Add a Zero Trust Access Path (Optional, Browser-Only)

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

Sometimes you need to grant access to a device without a client certificate, like a user 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 where the user authenticates with an email OTP or identity provider, and Access injects a signed JWT header into the proxied request.

Scope

This path works for browser-based access only. Native mobile apps that cannot follow a 302 redirect to the Cloudflare Access login page will not complete the authentication flow. For native app access on managed devices, use Step 4 instead.

3.1 Create an Access Application

  1. Navigate to Access controls > Applications
  2. Click Add an Application > Self-hosted
  3. Set the Application domain to the same subdomain: service.yourdomain.com
  4. Give the application a name
  5. 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.

Native App Note

The cf-access-jwt-assertion header is injected by Cloudflare for authenticated browser sessions that complete the Access login flow. Native apps that cannot follow the redirect-based login will never complete that flow and will not carry this header. This check does not provide a workaround for native app access.


Step 4: WARP Private Network Routing for Managed Devices

If you control the devices that need native app access (your own phones, tablets, or a managed fleet), Cloudflare WARP private network routing is the right solution. It bypasses the public hostname and Access policy entirely by routing traffic through your existing cloudflared tunnel using internal IP addresses, which is exactly how a traditional VPN works.

How it works:

When WARP is connected and enrolled in your Zero Trust organization, traffic to your private subnet (e.g., 10.10.0.0/24) is routed through Cloudflare's network and down your cloudflared tunnel to your internal network. Your native app connects to the service by internal IP and port (e.g., http://10.10.0.10:8080) rather than the public hostname. It never touches the public hostname, never encounters an Access policy, and never needs to present a client certificate.

This approach is documented by Cloudflare as the recommended method for replacing a traditional VPN for device-to-network access.

Requirements:

  • cloudflared running on your private network (you likely already have this if you're using a Cloudflare Tunnel)
  • Cloudflare One Client (WARP) installed and enrolled in your Zero Trust organization on each device
  • WARP enrolled with Gateway with WARP mode

Setup: three changes required

1. Add a private network route to your tunnel

In the Zero Trust dashboard, go to Networks > Tunnels > your tunnel > Edit > Private Network and add your internal subnet CIDR (e.g., 10.10.0.0/24). This tells cloudflared to accept and route WARP traffic destined for that subnet.

2. Remove your private subnet from the WARP Split Tunnel exclude list

Go to Team & Resources > Devices > Device profiles > your profile > Split Tunnels > Manage. By default, RFC 1918 private ranges are in the exclude list, which means WARP bypasses them and sends that traffic to the local network instead of through the tunnel. Delete the entry covering your subnet so WARP routes it through Cloudflare instead.

From the Cloudflare docs: "By default, WARP excludes traffic bound for RFC 1918 space, which are IP addresses typically used in private networks and not reachable from the Internet. In order for the Cloudflare One Client to send traffic to your private network, you must configure Split Tunnels so that the IP/CIDR of your private network routes through the Cloudflare One Client."

3. Connect and test

With WARP connected, open your native app and point it at the internal IP and port of your service. It should connect directly with no authentication prompt.

Known limitation: subnet overlap

If you happen to be on a network that uses the same subnet as your internal network (a hotel or coffee shop that assigns addresses in the same range), there will be a routing conflict and the tunnel cannot distinguish between local and remote traffic for that range. This only affects those specific overlapping network conditions and works correctly on mobile data and most other networks.

Public hostname access is unaffected. Your service.yourdomain.com public hostname, WAF rules, and Access policies remain exactly as configured. Browser users continue to authenticate normally. WARP routing is additive and simply provides an additional path for managed devices using internal IPs.


Step 5: Testing

Run through these tests in order after completing configuration.

5.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.

5.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.

5.3 Test the Access Path (if configured)

Authenticate with one of the email addresses in the Access policy via browser. Navigate to service.yourdomain.com. The service should load after Access authentication without requiring a client certificate.

5.4 Test the WARP Private Network Path (if configured)

Connect the Cloudflare One Client on your mobile device in Gateway with WARP mode. Open your native app and connect to the service using its internal IP address and port. It should connect immediately with no certificate prompt and no login page.

5.5 Verify Rule Ordering

If certificate holders are getting blocked, check rule ordering first. In the Security Rules > 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)
Native app fails to connect even with cert installedMost native apps do not present client certificates automatically (see the Native App Limitation section above and consider WARP private network routing in Step 4 instead)
WARP private network routing not workingRFC 1918 range is still in the Split Tunnel exclude list (remove the entry covering your internal subnet from the device profile Split Tunnels config)

Key Takeaways

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

Checkmark Zero Trust also has an mTLS section under Service credentials but it requires an Enterprise plan and a bring-your-own CA. This guide uses the free main dashboard approach.

Checkmark 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

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

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

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

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

Checkmark Most native mobile apps do not present client certificates automatically, even when the cert is installed on the device. This is a platform limitation and not a configuration error.

Checkmark For managed devices running native apps, WARP private network routing is the right solution. Route your internal subnet through the tunnel and connect apps directly by internal IP, bypassing the public hostname and Access policy entirely.


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.