September 10, 2026 / Security

Cloudflare Zero Trust Tunnel: Access Policies Explained

Quick answer

A Cloudflare Zero Trust tunnel is an outbound-only connection from your server to Cloudflare’s network, paired with an Access application and a policy that decides which identities may pass. The tunnel moves the traffic; Access decides who gets in. Cloudflare One is free for teams under 50 users and $7 per user per month above that. Most WordPress teams never leave the free tier.

You can have a working tunnel running in ten minutes and still have no idea who is allowed through it. That is the gap this post fills. The transport half of the story is covered well enough elsewhere, including in the step-by-step Cloudflare Tunnel setup guide.

What follows is the identity half: what a Cloudflare Zero Trust tunnel actually protects, how Access policies evaluate, what a service token is for, and precisely what breaks when you put one of these in front of WordPress. Two things break reliably. Both are fixable, and both are missed by every “put wp-admin behind Access in 5 minutes” post.

Prices, limits and policy semantics below are taken from Cloudflare’s own documentation and pricing page, linked where they appear. Nothing here is estimated.

What a Cloudflare Zero Trust tunnel actually is

A Cloudflare Zero Trust tunnel is four separate objects that people talk about as if they were one: a tunnel, a route, an application, and a policy. Getting them confused is the root cause of most of the confusion in support threads.

The tunnel is transport. The cloudflared daemon runs on your origin and dials outbound to Cloudflare, so your firewall never needs an inbound rule. The route is the destination: a hostname or an IP range that Cloudflare maps onto that tunnel. The application is the thing you have declared worth protecting, defined by domain and path. The policy is the rulebook that decides which humans or machines get through.

You can run a tunnel with no application and no policy at all. That gives you a publicly reachable site with no open ports, which is useful but is not Zero Trust in any sense. Access only exists once you create an application over that hostname.

ObjectWhat it isWhere you configure itSymptom when it is wrong
TunnelAn outbound connection from cloudflared to Cloudflare’s edgeCLI or the Cloudflare One dashboardError 1033, hostname unreachable
RouteThe hostname or CIDR mapped to that tunnelcloudflared tunnel route dns, or the Public Hostname tabDNS record conflicts, wrong origin answers
Ingress ruleWhich local service the tunnel hands the request toconfig.yml or the dashboardTunnel healthy but the hostname returns 502
ApplicationThe domain plus path you have placed under AccessCloudflare One, Access, ApplicationsNo login prompt appears at all
PolicyThe identity and posture rules deciding entryAttached to the applicationEveryone in, or nobody in
The four-layer split, as described in Cloudflare One’s tunnel and Access documentation.

Why cloudflared is not WARP

cloudflared is a server-side connector and WARP is a device-side agent, and they solve opposite ends of the same problem. Nearly everyone new to Cloudflare One installs one when they needed the other.

cloudflared is a daemon you install on the machine that holds the resource. It opens outbound connections to Cloudflare’s network so that requests can be delivered inward without you opening a port. It knows nothing about users.

The Cloudflare One Client, formerly WARP, installs on laptops and phones. It routes device traffic to Cloudflare over WireGuard or MASQUE, and it reports device health such as OS version and disk encryption state. That reporting is the only way client-side device posture signals reach an Access policy. If your policy requires a posture check and the user has no client installed, the check cannot pass.

 cloudflaredCloudflare One Client (WARP)
Installed onYour server, container or VMEnd-user laptops, phones, tablets
PurposeExpose a resource without open portsSend device traffic through Cloudflare
DirectionOutbound from origin to edgeOutbound from device to edge
Knows about usersNoYes, it carries device identity
Needed for browser access to a web appYesNo
Needed for device posture rulesNoYes, for client-side posture checks
Needed to reach a private IP rangeYes, for the routeYes, on the client side
Deployed byPackage manager, Docker, systemd unitMDM tools such as Intune, Jamf or JumpCloud
Roles per Cloudflare One documentation for Cloudflare Tunnel and the Cloudflare One Client.

The practical rule: if a browser on any machine should be able to reach the site after logging in, you need cloudflared only. If you want the resource reachable at a private IP with no public hostname, or you want posture rules, you need both.

How Access policies decide who gets in

Access policies are built from three rule types with fixed boolean meanings: Include is OR, Require is AND, and Exclude is NOT. A user needs to match only one Include criterion, must satisfy every Require criterion, and is refused if they match any Exclude criterion.

That distinction is where most misconfigurations live. Two email domains in Include means “either domain”. Two email domains in Require means “an address that is somehow in both domains at once”, which nobody is, so the policy never matches and you have quietly locked yourself out.

The four policy actions

  • Allow lets matching users through after they authenticate.
  • Block refuses matching users outright.
  • Bypass disables Access enforcement for matching traffic. Cloudflare’s own wording is blunt: bypassed requests get no Access security controls and are not logged.
  • Service Auth authenticates without an identity provider login, using a service token or mutual TLS. Set the action to Service Auth or Access will still demand an interactive login.

Worked example 1: contractors with a device check

Goal: people at one agency may reach the staging site, but only from a managed device.

  • Action: Allow
  • Include: Emails ending in @agency.example
  • Require: Device posture check, disk encryption enabled
  • Require: Login method is your identity provider, not one-time PIN

The two Require rules are ANDed, so an agency address on an unmanaged laptop fails. Note that this policy only works if those contractors run the Cloudflare One Client, since client-side posture signals come from the agent.

Worked example 2: staff, minus a leaver, minus a shared mailbox

  • Action: Allow
  • Include: Identity provider group “Editors” OR emails ending in @yourcompany.com
  • Require: Country is one of your operating countries
  • Exclude: billing@yourcompany.com

The Exclude wins over the Include, which is the point. Shared mailboxes are the classic hole: several people know that password, and a group membership rule will happily wave it through.

Identity providers: what free gets you, and what changes when you wire up a real IdP

Out of the box you get identity without configuring anything, but you get identity with no groups. New Cloudflare One organizations use the Cloudflare identity provider as the default login method, and one-time PIN can be added at any time. A one-time PIN is emailed to the user and expires ten minutes after the request.

That is enough for a two-person team locking down a staging box. The limitation is that your only usable identity signal is the email address itself, so every policy becomes an email list or a domain match. There is also a documented trap: if a user authenticates with one-time PIN rather than your identity provider, Access stops evaluating their identity provider group memberships.

Connecting Google Workspace, Okta, Microsoft Entra ID or GitHub changes the shape of what you can write. You get group membership as a policy condition, which means access follows the HR system rather than a list you maintain by hand. Deprovisioning becomes real: disable the account in the directory and the next session refresh fails. GitHub is the odd one out and the useful one for agencies, since organization and team membership map neatly onto who should see a staging site.

Pro tip

Keep one-time PIN enabled alongside your IdP as a break-glass path, but write your production policy so PIN logins are excluded by a Require rule on login method. Otherwise you have built a group-based policy that anyone with a mailbox on the right domain can sidestep.

Service tokens, and the Bypass action people misuse

Service tokens are how machines get through Access without a browser, and they are almost always the correct answer to “my integration broke”. A token is a client ID and secret pair that a caller sends as the CF-Access-Client-Id and CF-Access-Client-Secret headers, matched by a policy whose action is Service Auth. Token duration is set in hours, so a one-year token is expressed as 8760h.

curl -sS https://staging.example.com/wp-json/wp/v2/posts \
  -H "CF-Access-Client-Id: $CF_ACCESS_CLIENT_ID" \
  -H "CF-Access-Client-Secret: $CF_ACCESS_CLIENT_SECRET"

Bypass is the tempting shortcut and the wrong one in most cases. It does not authenticate anything. It switches Access off for traffic matching the rule, and those requests do not appear in your Access logs, so you lose the audit trail exactly where you most wanted it. A Bypass rule scoped to “everyone” on a path is a public path, stated in a way that looks like security.

If your fix for a broken integration is a Bypass rule, you have not fixed it. You have removed the control and kept the dashboard entry that says you have one.

There is a hard ceiling worth knowing before you design around tokens: Cloudflare One accounts allow 50 service tokens, 500 Access applications, 1,000 rules per application, and 1,000 cloudflared tunnels. Fifty tokens goes quickly if you issue one per site per integration, so issue per integration and scope by policy instead.

Cloudflare Zero Trust vs Tailscale vs VPN vs a bastion host

These four solve overlapping problems with genuinely different routing models, and the honest differentiator is where the packets go. Cloudflare relays every request through a data centre in its network. Tailscale tries not to relay at all.

Tailscale’s documentation states that all connections start relayed through a DERP server and are then upgraded to a direct UDP connection where the network allows it, and that direct connections usually give the lowest latency and highest throughput. Cloudflare’s model has no equivalent upgrade path: the edge is always in the middle. For a browser reaching a public web app that is fine, because the edge was already in the path. For a chatty database session between two machines in the same building, it is a real cost.

 Cloudflare Zero TrustTailscaleTraditional VPNBastion / SSH jump host
Routing modelAlways relayed via Cloudflare’s edgePeer-to-peer where possible, DERP relay as fallbackHub and spoke through a concentratorSingle hop through one hardened host
Latency behaviourAdds an edge hop on every requestDirect path once the connection upgradesAll traffic detours via the concentratorOne extra hop, then LAN speed
Identity integrationNative SAML, OIDC and OAuth, per-application policiesSSO-based, ACLs by user and tagUsually LDAP or RADIUS, network-level grantsSSH keys, or certificates if you build it
Client softwareNone for web apps, agent for posture and private IPsAgent required on every deviceAgent required on every deviceNone beyond an SSH client
GranularityPer hostname and per pathPer host and per portTypically whole network segmentsPer host account
Audit trailPer-request login events, unless Bypass is usedConnection logsSession logsWhatever you configure and ship yourself
Best fitWeb apps and admin panels, mixed internal and contractor accessMachine-to-machine mesh, dev environments, high-throughput linksLegacy apps that need real layer 3 reachabilityOne or two engineers, servers only, minimal budget
Routing behaviour per Cloudflare One and Tailscale connection-type documentation. No latency figures are quoted because the honest answer depends entirely on your network path.

What it costs at 5, 25 and 100 users

Cloudflare is free below 50 users and Tailscale is free below 7, which decides the small end of the table on its own. Cloudflare’s plans page lists the free tier as teams under 50 users at $0, pay-as-you-go at $7 per user per month, and a custom annual contract above that. Tailscale’s pricing page lists Personal as free for up to 6 users with unlimited devices, Standard at $8 per user per month and Premium at $18.

UsersCloudflare Zero TrustTailscale StandardTraditional VPNBastion host
5$0 (free tier)$0 (Personal, up to 6 users)Appliance or licence cost, plus your timeOne small VM, plus your time
25$0 (free tier)$200/mo at $8 per userLicensing usually tiered, quote-drivenSame VM, more key management
100$700/mo at $7 per user, or contract pricing$800/mo at $8 per userQuote-driven, plus hardware refreshNot advisable at this size
Worked example arithmetic from the published list prices linked above, August 2026. VPN and bastion columns are deliberately unpriced: neither has a published flat rate, and the real cost is administration.

What the Cloudflare free tier does not include: Browser Isolation is an add-on to paid plans, and Zero Trust Logpush is Enterprise only. If your compliance story depends on shipping access logs to your own SIEM, price the Enterprise conversation from the start rather than discovering it at audit time.

Putting /wp-admin behind a Cloudflare Zero Trust tunnel

Protecting wp-admin with Access takes one tunnel, one self-hosted application scoped to a path, and one policy. This is the strongest single change you can make to a WordPress login surface, because unauthenticated requests never reach PHP at all. It pairs well with the rest of the essential steps to secure a WordPress site rather than replacing them.

cloudflared tunnel login
cloudflared tunnel create wp-prod
cloudflared tunnel route dns wp-prod www.example.com
cloudflared tunnel run wp-prod

A minimal locally managed config.yml, which is what you want once you move off the foreground run command and install the service:

tunnel: 6ff42ae2-765d-4adf-8112-31c55c1551ef
credentials-file: /root/.cloudflared/6ff42ae2-765d-4adf-8112-31c55c1551ef.json
ingress:
  - hostname: www.example.com
    service: http://localhost:8080
  - service: http_status:404

Then, in Cloudflare One, add a self-hosted application. Set Subdomain to www, Domain to example.com and Path to wp-admin. Add a second application for wp-login.php, because the login file sits outside the wp-admin directory and a path-scoped application does not cover it. Set session duration deliberately: the Access default is 24 hours, and the range runs from immediate timeout up to one month.

Path precedence follows specificity. A rule set on a more specific path takes priority over one set on its parent, and a path with no rule of its own inherits from the parent. Note the wildcard subtlety: example.com/alpha/* covers /alpha/one but not /alpha itself.

Staging sites without open ports or IP allowlists

Staging is the better first project, because the blast radius of getting it wrong is a broken staging site rather than a broken shop. Run cloudflared on the staging box, route a dedicated subdomain to it, and place a single Access application over the whole hostname with no path. Every request now requires a login, HTTP basic auth disappears from your life, and you stop maintaining an IP allowlist that breaks every time a developer’s home connection renews its address. Pair it with a real WordPress password policy so the second factor is not carrying the whole system.

The two things that break WordPress behind Access

Access blocks machine traffic and it changes what is cacheable, and both surprises land within days of going live. Neither is a bug. Both are consequences of Access checking every HTTP request for a valid CF_Authorization cookie and blocking requests that do not carry one.

REST API and cron callers get shut out

Third-party services calling /wp-json/ have no browser, no cookie and no way to complete a login redirect. Your uptime monitor, your headless front end, your backup plugin’s remote trigger and any external cron runner all stop working the moment the hostname goes under Access. The symptom is usually reported as “the API returns HTML”, because what comes back is the Access login page.

The correct fix is a Service Auth policy on the same application, matched to a service token, with the calling service configured to send the two CF-Access-Client-* headers. If the calling service genuinely cannot send custom headers, a Bypass policy narrowly scoped to /wp-json/ is the fallback, and you should treat it as what it is: that path is now public and unlogged. Never scope a Bypass to the site root.

Watch out

WordPress core’s own loopback request to wp-cron.php originates from your server and exits through your public hostname. If that hostname is fully under Access, scheduled posts and plugin cron jobs stop silently. Disable WP-Cron and run a real system cron against the local address instead.

Access sits in front of cache

An Access-protected hostname is not a cacheable hostname, and scoping matters more than people expect. Every request to a protected path is evaluated for identity before anything else can serve it, and the authenticated response is tied to a per-user JWT held in the CF_Authorization cookie. Cloudflare’s default cache behaviour already declines to cache a response carrying Set-Cookie, and does not cache HTML by default at all.

For wp-admin that is exactly right, since nothing there should be shared between users. The failure mode is scoping the application to the whole domain when you only meant to protect the admin. Do that on a production site with a page cache in front of it and you have converted every anonymous pageview into an authentication check. If you are unsure what your edge is currently doing, the Cloudflare cache bypass diagnostics guide covers reading the response headers, and Cache Inspector in the WPColt toolbox will tell you whether the page you think is cached actually is.

Troubleshooting the errors you will actually hit

Five failures account for most of the pain, and each has a distinct signature. Read the error before you change anything, because the fixes are not interchangeable.

  • Error 1033. Cloudflare cannot find a healthy cloudflared instance for that tunnel. Check the tunnel’s status in the dashboard: Inactive means the daemon was never run on the origin, Down means the service has stopped, Degraded means some connections are failing and you should read the logs for firewall interference. This is a tunnel problem, never an Access problem.
  • Tunnel healthy, hostname returns 502. The tunnel reached Cloudflare but cloudflared cannot reach your local service. Usual causes are the wrong port in the ingress rule, the service not listening, or an HTTP versus HTTPS mismatch against an origin certificate cloudflared does not trust.
  • DNS record conflicts. Adding a public hostname fails when a record already exists for that name. Delete the old A or CNAME record, or pick a different hostname. A stale A record pointing at the old origin IP is also how you end up bypassing the tunnel entirely without noticing.
  • Policy evaluation surprises. Almost always a Require rule that should have been an Include, or a Bypass policy sitting above the Allow policy you thought was in force. If a path is unexpectedly open, search your applications for Bypass first.
  • Re-authentication loops. Access issues a global token and per-application tokens. A new application token is issued automatically while the global token is still valid, so setting an application session longer than the global one produces repeated logins. Keep the global duration equal to or longer than the application duration.

One more that looks like a bug and is not: one-time PIN emails are single-use, and some corporate mail scanners follow links and consume them before the human clicks. If a user swears the PIN never works, that is usually why.

Verdict: who should run this, and who should not

Put a Cloudflare Zero Trust tunnel in front of wp-admin if you run more than one WordPress site, have contractors coming and going, and are already on Cloudflare. Under 50 users it costs nothing, it removes the login form from the public internet, and it gives you a per-request audit trail no security plugin can match. Agencies with a staging environment per client get the most out of it, and it is the single change I would make first.

Do not use it if your access problem is machine-to-machine between servers on the same network. Every request takes an edge hop by design, and a mesh built for direct peer connections is the better tool. Do not use it if a single engineer needs shell access to two servers and nothing else, because a bastion host and SSH certificates cost less and involve no vendor.

And do not deploy it on a busy production front end without deciding your caching story first. Scope the application to the admin paths, keep the anonymous side of the site cacheable, and verify with headers rather than assumption. An Access policy that quietly makes your homepage uncacheable is a performance regression wearing a security badge.

Frequently asked questions

What happens if I use Require instead of Include for two email domains in an Access policy?

Two domains in Require means an address must belong to both domains at once, which no address ever does, so the policy never matches and locks everyone out. Two domains in Include means either domain qualifies. Include is OR, Require is AND, and mixing them up is the most common misconfiguration in Access policies.

Do I need the Cloudflare One Client installed to protect wp-admin?

No. cloudflared alone is enough for a browser to reach a self-hosted application after logging in, since cloudflared only handles the outbound connection from your server. The Cloudflare One Client, formerly WARP, is only required when a policy needs device posture signals such as disk encryption state, or when the resource sits at a private IP with no public hostname.

How do I stop wp-cron from failing once a WordPress site sits behind Access?

WordPress core’s loopback request to wp-cron.php leaves your server through the public hostname, so a fully Access-protected domain blocks its own cron call. Disable WP-Cron in wp-config.php and trigger wp-cron.php with a real system cron job hitting the local address instead, bypassing the public hostname and the Access check entirely.

Why does a REST API integration suddenly return HTML instead of JSON after adding Access?

Third-party callers such as uptime monitors, headless front ends and backup plugins have no browser and no CF_Authorization cookie, so Access serves them its login page instead of the API response, which looks like the API returning HTML. The fix is a Service Auth policy matched to a service token, with the caller sending the CF-Access-Client-Id and CF-Access-Client-Secret headers.

Is a Bypass policy a safe way to fix an integration that Access is blocking?

Not really. Bypass switches Access enforcement off entirely for matching traffic, so those requests are not authenticated and are not logged, removing your audit trail. It is an acceptable narrow fallback scoped to something like /wp-json/ only when a caller genuinely cannot send custom headers, but it should never be applied to a site root or treated as a real fix.

How many service tokens can one Cloudflare One account create?

Cloudflare One accounts are limited to 50 service tokens, alongside 500 Access applications, 1,000 rules per application and 1,000 cloudflared tunnels. Issuing one token per site per integration burns through that ceiling quickly, so the better pattern is to issue tokens per integration and control scope through the policy attached to each application instead.

When does Tailscale make more sense than a Cloudflare Zero Trust tunnel?

For machine-to-machine traffic between servers on the same network, since Tailscale upgrades to a direct peer-to-peer connection where possible while Cloudflare always relays through its edge, adding a hop on every request. Cloudflare fits web apps and admin panels better because a browser reaching a public site already goes through the edge anyway.

Why do some users get stuck in a repeated login loop behind Access?

Access issues a global token plus a per-application token, and a new application token is reissued automatically while the global token is still valid. If the application session duration is set longer than the global session duration, the global token expires first and forces a fresh login on every visit. Keep the global duration equal to or longer than the application duration.