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.
| Object | What it is | Where you configure it | Symptom when it is wrong |
|---|---|---|---|
| Tunnel | An outbound connection from cloudflared to Cloudflare’s edge | CLI or the Cloudflare One dashboard | Error 1033, hostname unreachable |
| Route | The hostname or CIDR mapped to that tunnel | cloudflared tunnel route dns, or the Public Hostname tab | DNS record conflicts, wrong origin answers |
| Ingress rule | Which local service the tunnel hands the request to | config.yml or the dashboard | Tunnel healthy but the hostname returns 502 |
| Application | The domain plus path you have placed under Access | Cloudflare One, Access, Applications | No login prompt appears at all |
| Policy | The identity and posture rules deciding entry | Attached to the application | Everyone in, or nobody in |
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.
| cloudflared | Cloudflare One Client (WARP) | |
|---|---|---|
| Installed on | Your server, container or VM | End-user laptops, phones, tablets |
| Purpose | Expose a resource without open ports | Send device traffic through Cloudflare |
| Direction | Outbound from origin to edge | Outbound from device to edge |
| Knows about users | No | Yes, it carries device identity |
| Needed for browser access to a web app | Yes | No |
| Needed for device posture rules | No | Yes, for client-side posture checks |
| Needed to reach a private IP range | Yes, for the route | Yes, on the client side |
| Deployed by | Package manager, Docker, systemd unit | MDM tools such as Intune, Jamf or JumpCloud |
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 Trust | Tailscale | Traditional VPN | Bastion / SSH jump host | |
|---|---|---|---|---|
| Routing model | Always relayed via Cloudflare’s edge | Peer-to-peer where possible, DERP relay as fallback | Hub and spoke through a concentrator | Single hop through one hardened host |
| Latency behaviour | Adds an edge hop on every request | Direct path once the connection upgrades | All traffic detours via the concentrator | One extra hop, then LAN speed |
| Identity integration | Native SAML, OIDC and OAuth, per-application policies | SSO-based, ACLs by user and tag | Usually LDAP or RADIUS, network-level grants | SSH keys, or certificates if you build it |
| Client software | None for web apps, agent for posture and private IPs | Agent required on every device | Agent required on every device | None beyond an SSH client |
| Granularity | Per hostname and per path | Per host and per port | Typically whole network segments | Per host account |
| Audit trail | Per-request login events, unless Bypass is used | Connection logs | Session logs | Whatever you configure and ship yourself |
| Best fit | Web apps and admin panels, mixed internal and contractor access | Machine-to-machine mesh, dev environments, high-throughput links | Legacy apps that need real layer 3 reachability | One or two engineers, servers only, minimal budget |
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.
| Users | Cloudflare Zero Trust | Tailscale Standard | Traditional VPN | Bastion host |
|---|---|---|---|---|
| 5 | $0 (free tier) | $0 (Personal, up to 6 users) | Appliance or licence cost, plus your time | One small VM, plus your time |
| 25 | $0 (free tier) | $200/mo at $8 per user | Licensing usually tiered, quote-driven | Same VM, more key management |
| 100 | $700/mo at $7 per user, or contract pricing | $800/mo at $8 per user | Quote-driven, plus hardware refresh | Not advisable at this size |
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
cloudflaredinstance 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
cloudflaredcannot 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 certificatecloudflareddoes 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.