Closing the Front Door, A Hands-On Guide to Cloudflare Zero Trust Access for Internal Web Apps

By Eyüp Canbay on Nov 13, 2026, 11:54:04 AM

cloudflare-zero-trust-access-internal-web-apps-security (1)

Most teams still give remote access to internal infrastructure the same way they did ten years ago, a bastion host with an open SSH port, or a VPN concentrator with a shared client config. Both work. Both also carry a cost that only shows up when something goes wrong a port that gets scanned within minutes of going live, a departing employee whose VPN profile nobody remembers to revoke, a login that leaves no record of which resource was actually touched.

In this post, I'll walk through replacing that model with Cloudflare Zero Trust, a tunnel that keeps the origin server closed to all inbound traffic, and an identity-aware proxy that decides, per request, who gets through. I set this up originally just to see whether it held up under real use, and it's turned into the access pattern I'd now default to over a bastion host.

What's Inside?

  1. What is Cloudflare Zero Trust Access? the tunnel, and the identity-aware proxy sitting in front of it
  2. Why Move Off a Bastion Host or VPN? the four problems a network-level access model leaves open
  3. Setting It Up writing the Access Policies, wiring them into the Access Application, and the precedence order that decides which one wins
  4. Bastion/VPN vs. Zero Trust Tunnel a side-by-side comparison and what each column actually costs you operationally
  5. What This Looks Like Day to Day what changes for the person logging in, and for whoever has to investigate afterward

 

What is Cloudflare Zero Trust Access?

Cloudflare Zero Trust Access sits in front of a hostname and evaluates every request against a policy before it's allowed to reach the origin. It's paired with Cloudflare Tunnel (cloudflared), a daemon that runs on the origin and opens an outbound connection to Cloudflare's edge. Traffic to the hostname is carried over that outbound connection, so the origin never needs an open inbound port.

The combination replaces two things a bastion host or VPN does together getting onto the network, and being allowed to use what's on it with one decision made per request, is this identity, from this device, with this authentication, allowed to reach this specific hostname, right now.

 

 

Why Move Off a Bastion Host or VPN?

A bastion host or a traditional VPN grants network-level access, once a client connects, it can reach anything routable behind that gateway. The policy check happens once, at connect time, and not again for anything the client touches afterward. A few problems follow from that:

The entry point is always listening. An open inbound port is scanned whether or not anyone is using it right now.

Credentials sprawl. SSH keys end up in password managers, on laptops, in CI runners and rarely get rotated or fully revoked.

Access is binary. A VPN profile either lets someone onto the network or it doesn't. There's no native way to say “only from a managed device” or “only with a second factor.”

Audit trails are thin. A VPN log shows a connection. It doesn't show which internal host, path, or action followed it.

Setting It Up

Step 1 Close the origin and establish the tunnel

On the internal host, install cloudflared and authenticate it to the Cloudflare account:

curl -L https://pkg.cloudflare.com/cloudflared-linux-amd64.deb -o cloudflared.deb

sudo dpkg -i cloudflared.deb
cloudflared tunnel login


Create the tunnel:

cloudflared tunnel create internal-portal


Tell it what to forward to, in config.yml:

tunnel: internal-portal
credentials-file: /etc/cloudflared/internal-portal.json

ingress:
- hostname: portal.example.com
service: http://localhost:8080
- service: http_status:404


Start the tunnel as a service but don't route DNS yet:


cloudflared service install


At this point the host's firewall can drop every inbound rule for this service the only way in is the outbound tunnel the host itself opened. portal.example.com doesn't resolve to anything yet, and that's deliberate, DNS comes last, after the Access Application and its policies are already in place.

 

Step 2 Write the Access Policies

A policy is a rule evaluated against the request's identity context, and it's created as its own resource, reusable across applications. Access evaluates policies in two separate groups: Bypass and Service Auth policies are checked first, independent of precedence; Allow and Deny policies are checked after, in precedence order. Start with the first group a Service Auth policy for a caller that can't go through an SSO login, like an uptime monitor hitting the hostname on a schedule. It still needs a credential, just not a human one:

resource "cloudflare_zero_trust_access_service_token" "healthcheck" {

account_id = var.cloudflare_account_id

name = "Uptime Monitor"

}

 

resource "cloudflare_zero_trust_access_policy" "service_auth_healthcheck" {

account_id = var.cloudflare_account_id

name = "Service Auth for Uptime Monitor"

decision = "non_identity"

 

include = [{

service_token = { token_id = cloudflare_zero_trust_access_service_token.healthcheck.id }

}]

}


Next, a policy that denies a group outright, regardless of what else is true about them:

resource "cloudflare_zero_trust_access_policy" "deny_contractors" {
account_id = var.cloudflare_account_id
name = "Deny Contractors"
decision = "deny"

include = [{
group = { id = var.contractors_group_id }
}]
}


And the policy that actually lets people in: engineering, on the corporate domain, with MFA completed. The domain and group checks go under require, since both have to be true; include is left at everyone because there's no either/or condition here:

resource "cloudflare_zero_trust_access_policy" "allow_engineering" {
account_id = var.cloudflare_account_id
name = "Allow Engineering with MFA"
decision = "allow"

include = [{
everyone = {}
}]

require = [
{ email_domain = { domain = "example.com" } },
{ group = { id = var.engineering_group_id } },
]

mfa_config = {
allowed_authenticators = ["totp", "security_key"]
}
}

 

 

Step 3 Define the Access Application

Create the Access Application for portal.example.com and attach the three policies to it. Service Auth doesn't need a precedence number to “win” Access checks it before Allow/Deny regardless but Terraform still requires every entry in the list to have one:

resource "cloudflare_zero_trust_access_application" "portal" {
account_id = var.cloudflare_account_id
name = "Internal Portal"
domain = "portal.example.com"
session_duration = "8h"

policies = [
{ id = cloudflare_zero_trust_access_policy.service_auth_healthcheck.id, precedence = 0 },
{ id = cloudflare_zero_trust_access_policy.deny_contractors.id, precedence = 1 },
{ id = cloudflare_zero_trust_access_policy.allow_engineering.id, precedence = 2 },
]
}


Precedence here only governs the Allow/Deny pair deny_contractors has to sit before allow_engineering, or a contractor in the engineering group would match the allow rule first. service_auth_healthcheck's precedence value is almost incidental, Access evaluates Service Auth and Bypass policies as their own group, before Allow/Deny, no matter what number they carry.

 

Step 4 Route DNS now that Access is in place

With the policies attached to the application, it’s safe to make the hostname resolve. If the DNS record existed before the Access Application did, the hostname would be reachable with no policy in front of it at all:

cloudflared tunnel route dns internal-portal portal.example.com

 

From here on, every request to portal.example.com is evaluated against the policies above before it ever reaches cloudflared there’s no window where the hostname is both resolvable and unprotected.

 

Step 5 Policy precedence: the detail that trips people up

Access evaluates policies in two separate groups. Bypass and Service Auth policies are checked first, from top to bottom, independent of precedence. Allow and Deny policies are checked after, and that's where precedence and first-match-wins actually apply.

Within that second group, the order is deliberate, not arbitrary: deny_contractors sits at precedence 1, ahead of allow_engineering at precedence 2. A contractor who also happens to sit in the engineering group is still denied, because their request never reaches the allow rule at all. If the allow rule were checked first instead, it would match before the deny rule got a chance to.

Get this ordering backwards within the Allow/Deny group and a rule simply never fires: a broad allow placed ahead of a narrower deny matches first, every time, and the deny becomes dead configuration. Service Auth and Bypass don't have this problem they're not racing Allow/Deny for a match, they're checked before either one gets a chance to run.

 

Key Considerations

Exposed surface. A bastion host or VPN concentrator keeps an inbound port open around the clock. A Zero Trust tunnel keeps none cloudflared only ever opens an outbound connection, so there's nothing on the public internet to scan.

Auditability. A VPN log records a single “connected” event for the whole session. Access's authentication log records each login decision individually which policy matched, which identity, allowed or denied which is already more than a VPN gives you by default. Request-level detail beyond that, every path, every subsequent request in the session, needs Logpush's Access Requests dataset, which is an Enterprise feature. Worth knowing before promising a per-request audit trail on a lower plan.

Onboarding and offboarding. Revoking a leaving employee's access means distributing or revoking a VPN profile or SSH key today. With Access, it means removing them from an identity provider group the same action you'd already take when someone leaves.

Granularity. A VPN is all-or-nothing at the network level. Access is per-hostname, per-policy, and can require MFA or a managed device on top of identity controls a VPN profile has no native way to express.

What This Looks Like Day to Day

A user visiting portal.example.com is redirected to the identity provider login, then through the MFA challenge if the policy requires it. On the operator's side, that login decision, allowed, denied, which policy matched, lands in the Access authentication log by default. Compare that to a VPN log that shows nothing beyond “connected”, even the default here is an improvement, before Logpush ever enters the picture.

Conclusion

Closing the inbound port and moving the access decision to an identity-aware proxy removes the most common failure modes of a bastion host or VPN, a scannable entry point, access that outlives the person who needed it, and an audit log that shows a connection but not an action. It's also a migration that doesn't need a cutover window it can be rolled out one hostname at a time, running next to the existing VPN until every hostname has moved over.

References

· Cloudflare Tunnel Connect networks

· Cloudflare Access Policies

· Terraform Registry cloudflare_zero_trust_access_application

· Terraform Registry cloudflare_zero_trust_access_policy

FAQ

What is the difference between Cloudflare Zero Trust Access and a VPN?

A VPN checks you once, when you connect, and then lets you reach anything on the network behind it. Cloudflare Zero Trust Access checks every request to a specific hostname. It looks at who you are, which device you're on and how you signed in before it lets anything through to the origin.

Does the origin server need an open inbound port with Cloudflare Tunnel?

No. The cloudflared daemon on the origin opens an outbound connection to Cloudflare's edge, and traffic comes back over that connection. You can drop every inbound firewall rule for the service, so there's nothing on the public internet to scan.

Why should DNS be routed only after the Access Application is created?

If the DNS record exists before the Access Application and its policies, the hostname resolves with no policy in front of it. Routing DNS last means there's no moment when the hostname is both reachable and unprotected.

How does Cloudflare Access policy precedence work?

Access checks policies in two groups. Bypass and Service Auth policies go first, and their precedence number doesn't matter. Allow and Deny policies come next, in precedence order, and the first match wins. That's why a narrow Deny rule has to sit ahead of a broad Allow rule, or the Deny never fires.

Does Cloudflare Access give a per-request audit trail on every plan?

Not by default. The Access authentication log records each login decision: the identity, which policy matched, and whether it was allowed or denied. To log every request and path within a session, you need Logpush's Access Requests dataset, which is an Enterprise feature.

Why do we think that the expansion of the Universe is accelerating?

Lorem ipsum dolor sit amet, consectetuer adipiscing elit. Aenean commodo ligula eget dolor. Aenean massa. Cum sociis natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus. Donec quam felis, ultricies nec, pellentesque eu, pretium quis, sem. Nulla consequat massa quis enim. Donec pede justo, fringilla vel, aliquet nec, vulputate eget, arcu. In enim justo, rhoncus ut, imperdiet a, venenatis vitae, justo. Nullam dictum felis eu pede mollis pretium. Integer tincidunt. Cras dapibus. Vivamus elementum semper nisi.

Back to top

Get Email Notifications

No Comments Yet

Let us know what you think