Why VPNs Are No Longer Sufficient
The traditional network perimeter β "inside the firewall is safe, outside is hostile" β collapsed the moment cloud adoption began. Today, your users are in 40 countries, your workloads are split across AWS, GCP, and three data centers, and your "trusted network" is a hotel WiFi in Jakarta. VPNs respond to this by creating a large encrypted tunnel back to a hub, then trusting everything that comes through it.
The problem: once an attacker compromises a single VPN credential, they're on your "trusted" network. They can move laterally to every system that VPN user could reach. And VPN users typically have access to far more than they need. In 2025, 67% of enterprise breaches involved lateral movement from a single compromised credential β almost always an overprivileged VPN user (Verizon DBIR 2025).
Zero Trust Network Access solves this with a simple principle: never trust, always verify. Every request to every resource is authenticated, authorized, and encrypted β regardless of where it originates. There is no implicit trust from network location.
Zero Trust Core Principles
Zero Trust is an architecture built on five pillars:
- Verify explicitly: Always authenticate and authorize using all available data points (identity, device health, location, service, data classification)
- Use least privilege access: Limit user access with just-in-time and just-enough-access. Scope access per session, not per employee
- Assume breach: Design as if the attacker is already inside. Minimize blast radius, segment access, encrypt everything, monitor everything
- Verify device posture: Trust decisions factor in whether the device is managed, patched, and compliant
- Continuous evaluation: Trust is not granted at login and assumed forever β it is re-evaluated continuously and revoked when risk changes
Zero Trust Architecture Components
A complete ZTNA implementation has these components working together:
User/Device β Identity Provider (IdP) β Policy Engine β Policy Enforcement Point β Resource
Components:
βββ Identity Provider (Okta, Azure AD, Google Workspace)
β βββ MFA enforcement
β βββ Conditional access policies
β βββ SSO federation
βββ Device Trust Agent (CrowdStrike, Jamf, Intune)
β βββ Certificate-based device identity
β βββ Posture assessment (patched? encrypted? MDM enrolled?)
β βββ Continuous health reporting
βββ Policy Engine (Cloudflare Access, Pomerium, BeyondCorp)
β βββ Evaluates: identity + device + network + behavior
β βββ Makes allow/deny decisions per request
β βββ Logs every access decision
βββ Policy Enforcement Point (PEP) / Identity-Aware Proxy
β βββ Sits in front of every resource
β βββ Enforces PDP decisions
β βββ Injects identity headers for downstream apps
βββ Resources (applications, APIs, SSH hosts, databases)
βββ Never directly exposed to internet
βββ Trust PEP headers, not network location
Implementation: Cloudflare Access (Cloud-Native ZTNA)
Cloudflare Access is the fastest path to production ZTNA. Your applications connect to Cloudflare's network via a lightweight tunnel (cloudflared), and users access them through Cloudflare's edge β your app is never exposed to the public internet.
Setting Up Cloudflare Tunnel + Access
# Install cloudflared
curl -L --output cloudflared.deb https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-amd64.deb
dpkg -i cloudflared.deb
# Authenticate with your Cloudflare account
cloudflared tunnel login
# Create a named tunnel
cloudflared tunnel create production-internal
# Configure tunnel (connects internal services to Cloudflare edge)
cat > ~/.cloudflared/config.yml << 'EOF'
tunnel: <TUNNEL-UUID>
credentials-file: /home/user/.cloudflared/<TUNNEL-UUID>.json
ingress:
# Internal apps accessible via Cloudflare Access
- hostname: jira.company.com
service: http://localhost:8080
- hostname: grafana.company.com
service: http://localhost:3000
- hostname: gitlab.company.com
service: http://localhost:80
# SSH via Cloudflare Tunnel (no exposed SSH port!)
- hostname: bastion.company.com
service: ssh://localhost:22
# Catch-all (must be last)
- service: http_status:404
EOF
# Create DNS records pointing to tunnel
cloudflared tunnel route dns production-internal jira.company.com
cloudflared tunnel route dns production-internal grafana.company.com
# Run as systemd service
cloudflared service install
systemctl start cloudflared
Cloudflare Access Policies (Terraform)
terraform {
required_providers {
cloudflare = {
source = "cloudflare/cloudflare"
version = "~> 4.0"
}
}
}
# Jira - only employees, on managed devices
resource "cloudflare_access_application" "jira" {
zone_id = var.cloudflare_zone_id
name = "Jira"
domain = "jira.company.com"
session_duration = "8h"
# Enable device posture requirement
allowed_idps = [cloudflare_access_identity_provider.okta.id]
auto_redirect_to_identity = true
}
resource "cloudflare_access_policy" "jira_employees" {
application_id = cloudflare_access_application.jira.id
zone_id = var.cloudflare_zone_id
name = "Employees only - managed devices"
precedence = "1"
decision = "allow"
include {
email_domain = ["company.com"]
device_posture = [cloudflare_device_posture_rule.managed_device.id]
}
require {
# Must use Okta SSO (MFA enforced at Okta level)
identity_provider_id = [cloudflare_access_identity_provider.okta.id]
}
exclude {
# Block contractors from Jira
group = ["contractors-group-id"]
}
}
# Grafana - SRE team only, any device (but MFA required)
resource "cloudflare_access_application" "grafana" {
zone_id = var.cloudflare_zone_id
name = "Grafana"
domain = "grafana.company.com"
session_duration = "4h"
}
resource "cloudflare_access_policy" "grafana_sre" {
application_id = cloudflare_access_application.grafana.id
zone_id = var.cloudflare_zone_id
name = "SRE team"
precedence = "1"
decision = "allow"
include {
group = [var.sre_group_id]
}
}
# Device posture rule - check CrowdStrike status
resource "cloudflare_device_posture_rule" "managed_device" {
account_id = var.cloudflare_account_id
name = "Managed Device Check"
type = "crowdstrike_s2s"
description = "Device must have CrowdStrike running with score >= 70"
schedule = "1h"
expiration = "2h"
match {
platform = "windows"
}
match {
platform = "mac"
}
input {
connection_id = var.crowdstrike_connection_id
operator = ">="
score = "70"
}
}
SSH Access Without Exposed Ports
One of ZTNA's most powerful features: SSH access to servers without opening port 22 to the internet β or even to your corporate network. The connection goes through Cloudflare's tunnel with identity verification.
# Developer SSH config (~/.ssh/config)
Host *.company.com
ProxyCommand /usr/local/bin/cloudflared access ssh --hostname %h
IdentityFile ~/.ssh/id_ed25519
# SSH to server β cloudflared handles auth via browser/token
ssh user@bastion.company.com
# Browser opens, user authenticates via Okta
# Session token cached for duration of Access policy
# Short-lived SSH certificates (even more secure)
# Cloudflare Access can issue certificates that expire after 1 minute
# Configure on server:
cat /etc/ssh/sshd_config.d/cloudflare-access.conf
TrustedUserCAKeys /etc/ssh/ca.pub
AuthorizedPrincipalsFile /etc/ssh/allowed_principals/%u
Self-Hosted Option: Pomerium
For teams that can't use Cloudflare (air-gapped, data sovereignty requirements), Pomerium is the best open-source identity-aware proxy.
# docker-compose.yml for Pomerium
version: "3.9"
services:
pomerium:
image: pomerium/pomerium:latest
ports:
- "443:443"
- "80:80"
volumes:
- ./pomerium:/pomerium
- ./certs:/certs:ro
environment:
# Core config
AUTHENTICATE_SERVICE_URL: https://authenticate.company.com
SHARED_SECRET: POMERIUM_SHARED_SECRET
COOKIE_SECRET: POMERIUM_COOKIE_SECRET
# Identity provider (Okta example)
IDP_PROVIDER: okta
IDP_PROVIDER_URL: https://company.okta.com
IDP_CLIENT_ID: OKTA_CLIENT_ID
IDP_CLIENT_SECRET: OKTA_CLIENT_SECRET
# Routes config
CONFIG_FILE: /pomerium/config.yaml
restart: unless-stopped
# pomerium/config.yaml
authenticate_service_url: https://authenticate.company.com
routes:
# Jira - employees only
- from: https://jira.company.com
to: http://jira-internal:8080
policy:
- allow:
and:
- domain:
is: company.com
- groups:
has: employees
allow_websockets: true
# Grafana - SRE team
- from: https://grafana.company.com
to: http://grafana:3000
policy:
- allow:
and:
- groups:
has: sre-team
pass_identity_headers: true
# Development APIs - require specific claim
- from: https://api-internal.company.com
to: http://api-service:8000
policy:
- allow:
and:
- domain:
is: company.com
- claim/roles:
includes: developer
set_request_headers:
X-User-Email: "{{ .Email }}"
X-User-Groups: "{{ .Groups | join "," }}"
# SSH via TCP tunneling
- from: tcp+https://ssh.company.com:22
to: tcp://internal-bastion:22
policy:
- allow:
and:
- groups:
has: sre-team
Micro-Segmentation: Network-Level Zero Trust
ZTNA handles north-south traffic (user to resource). Micro-segmentation handles east-west (service to service). Without it, a compromised container can freely communicate with every other service in your cluster.
Kubernetes Network Policies
# Default-deny all ingress and egress in production namespace
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: default-deny-all
namespace: production
spec:
podSelector: {} # Matches all pods
policyTypes:
- Ingress
- Egress
# No ingress/egress rules = deny all
---
# Allow API service to reach only its own database
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: api-to-postgres
namespace: production
spec:
podSelector:
matchLabels:
app: api-service
policyTypes:
- Egress
egress:
- to:
- podSelector:
matchLabels:
app: postgres
ports:
- protocol: TCP
port: 5432
# Allow DNS
- to:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: kube-system
ports:
- protocol: UDP
port: 53
---
# Allow ingress from nginx only to API
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: nginx-to-api
namespace: production
spec:
podSelector:
matchLabels:
app: api-service
policyTypes:
- Ingress
ingress:
- from:
- podSelector:
matchLabels:
app: nginx-ingress
Service Mesh with mTLS (Cilium)
# Install Cilium with mTLS enforcement
helm install cilium cilium/cilium --namespace kube-system --set kubeProxyReplacement=true --set encryption.enabled=true --set encryption.type=wireguard # Pod-to-pod encryption
--set hubble.relay.enabled=true --set hubble.ui.enabled=true
# CiliumNetworkPolicy with L7 awareness
cat << 'EOF' | kubectl apply -f -
apiVersion: cilium.io/v2
kind: CiliumNetworkPolicy
metadata:
name: api-l7-policy
namespace: production
spec:
endpointSelector:
matchLabels:
app: api-service
ingress:
- fromEndpoints:
- matchLabels:
app: frontend
toPorts:
- ports:
- port: "8080"
protocol: TCP
rules:
http:
# Only allow specific HTTP paths/methods
- method: GET
path: /api/v1/products.*
- method: POST
path: /api/v1/cart
# Block admin endpoints from frontend
EOF
Continuous Verification: Detecting Anomalies Post-Login
Traditional auth: verify identity once at login, trust forever. Zero Trust: verify continuously, revoke when risk changes.
# Risk scoring engine example (Python + Redis)
import redis
import json
from datetime import datetime, timedelta
from dataclasses import dataclass
@dataclass
class AccessRequest:
user_id: str
resource: str
ip_address: str
user_agent: str
timestamp: datetime
geo_country: str
class ContinuousVerificationEngine:
def __init__(self):
self.redis = redis.Redis(host='localhost', port=6379, decode_responses=True)
def calculate_risk_score(self, request: AccessRequest) -> float:
"""Returns risk score 0.0 (low) to 1.0 (high)"""
score = 0.0
user_key = f"user:{request.user_id}"
# Check: Impossible travel
last_location = self.redis.hget(user_key, 'last_country')
last_time_str = self.redis.hget(user_key, 'last_access')
if last_location and last_time_str:
last_time = datetime.fromisoformat(last_time_str)
time_diff = (request.timestamp - last_time).total_seconds() / 3600
if last_location != request.geo_country and time_diff < 2:
score += 0.6 # Impossible travel - high risk
# Check: Unusual access time
hour = request.timestamp.hour
typical_hours = json.loads(self.redis.hget(user_key, 'typical_hours') or '[]')
if typical_hours and hour not in typical_hours:
score += 0.2 # Access outside normal hours
# Check: New device/user agent
known_agents = self.redis.smembers(f"{user_key}:agents")
if request.user_agent not in known_agents:
score += 0.15 # Unknown device
# Check: Failed attempts recently
failed_key = f"failed:{request.user_id}"
recent_failures = int(self.redis.get(failed_key) or 0)
if recent_failures > 3:
score += 0.3
# Check: High-velocity resource access (scraping?)
access_count_key = f"rate:{request.user_id}:{request.resource}"
access_count = self.redis.incr(access_count_key)
self.redis.expire(access_count_key, 300) # 5-minute window
if access_count > 100:
score += 0.4 # Suspicious high-frequency access
return min(score, 1.0)
def make_decision(self, request: AccessRequest) -> dict:
score = self.calculate_risk_score(request)
if score < 0.3:
return {'action': 'allow', 'score': score}
elif score < 0.6:
# Step up authentication required
return {'action': 'mfa_challenge', 'score': score}
else:
# Block and alert
self.alert_security_team(request, score)
return {'action': 'deny', 'score': score, 'reason': 'high_risk'}
Common Zero Trust Implementation Mistakes
Mistake 1: "Zero Trust" as marketing, not architecture
Buying a "Zero Trust" product and connecting it to your existing VPN-based network doesn't implement Zero Trust. You need to actually remove implicit network trust β default-deny at the network layer, not just add an SSO portal on top.
Mistake 2: Forgetting service-to-service authentication
ZTNA for users is step one. Your microservices also need Zero Trust: mutual TLS between services, workload identity (SPIFFE/SPIRE), and service-level authorization policies. Many "Zero Trust" implementations authenticate humans but leave services freely communicating.
Mistake 3: Not accounting for break-glass scenarios
# Always have a break-glass procedure:
# 1. Emergency access account (local, not IdP-dependent)
# 2. Documented in sealed physical location
# 3. Alerts when used (any use = incident trigger)
# 4. Regular testing (quarterly drill)
# Example: Pomerium bypass for emergency
# Keep one internal IP range with direct access to critical systems
# Only accessible from hardened admin workstation in HQ
# Monitor with: auditd + SIEM alerting on any use
Mistake 4: Not monitoring the policy engine
The policy engine is now the critical security control. Log every decision, every policy change, every admin action. The policy database is the new "crown jewel" β protect and audit it accordingly.
Mistake 5: Big-bang migration instead of incremental
Don't try to move everything to Zero Trust in one sprint. Prioritize by risk: start with internet-facing admin tools (Jira, GitLab, monitoring), then internal APIs, then database access. Each resource migrated reduces blast radius.
Zero Trust Maturity Model
Use this to assess and plan your ZTNA implementation:
| Level | Identity | Devices | Networks | Applications |
|---|---|---|---|---|
| Level 1 | SSO + MFA | Inventory only | VPN + firewall | Basic SSO |
| Level 2 | Conditional access | MDM + compliance | Micro-segmentation starts | Identity-aware proxy |
| Level 3 | Risk-based auth | Posture-based access | Software-defined perimeter | App-level authorization |
| Level 4 | Continuous verification | Automated remediation | Full micro-segmentation | Data-level access control |
| Optimal | Behavioral analysis | Hardware attestation | Default-deny everywhere | Real-time risk scoring |
Conclusion
Zero Trust is not a product you buy β it's an architecture you build. The good news is you don't need to boil the ocean. Start with a single high-value tool: put Cloudflare Access or Pomerium in front of your admin panel, require MFA, and add device posture. That single change eliminates the most common lateral movement vector. Then expand methodically, following the maturity model, until network location is irrelevant to your security posture.
The 2026 threat landscape makes this non-optional. Nation-state actors and ransomware operators specifically target VPN credentials as their initial access vector. Every organization that replaces "trusted network" with "verified identity" becomes dramatically harder to compromise.
Sarah Chen
Senior Cybersecurity Engineer with 12+ years of experience in penetration testing and security architecture.