Zero Trust Network Access (ZTNA)
From perimeter-based VPN security to per-request identity verification — the model that eliminates the concept of a trusted internal network.
1. The Problem With Perimeter Security
Traditional network security draws a hard boundary — everything inside the firewall is trusted, everything outside is not. Once you're on the VPN, on-prem, or in a peered VPC, you're fully trusted.
graph LR
classDef trusted fill:#27ae60,stroke:#1e8449,color:#fff
classDef untrusted fill:#e74c3c,stroke:#c0392b,color:#fff
classDef perimeter fill:#f39c12,stroke:#d68910,color:#fff
INTERNET["Internet — untrusted"]:::untrusted
FW["Firewall / VPN<br/>the perimeter"]:::perimeter
subgraph INSIDE["Inside the perimeter — fully trusted"]
APP["App server"]:::trusted
DB["Database"]:::trusted
ADMIN["Admin panel"]:::trusted
JENKINS["CI/CD server"]:::trusted
end
INTERNET -->|"blocked"| FW
FW -->|"VPN: once inside, everything reachable"| INSIDE
INSIDE -.->|"trusted: no re-verification"| DB
INSIDE -.->|"trusted: no re-verification"| ADMIN
INSIDE -.->|"trusted: no re-verification"| JENKINS
The flaw: network location is used as a proxy for identity. One phished credential, one rogue contractor, one misconfigured firewall rule — and the attacker is inside and can move laterally to anything.
An employee's VPN credentials are phished. Under the perimeter model, what can the attacker reach once they're connected?
2. Zero Trust: "Never Trust, Always Verify"
Zero Trust replaces network location as a trust signal with identity + device health + context, verified on every single request.
Three signals checked on every request
Under Zero Trust, does a user connecting from the office network get more access than one connecting from home WiFi?
3. VPN vs ZTNA
graph TD
classDef user fill:#34495e,stroke:#212f3c,color:#fff
classDef vpn fill:#e74c3c,stroke:#c0392b,color:#fff
classDef ztna fill:#27ae60,stroke:#1e8449,color:#fff
classDef app fill:#2980b9,stroke:#1f618d,color:#fff
classDef db fill:#8e44ad,stroke:#6c3483,color:#fff
subgraph VPN_MODEL["VPN model"]
U1["User"]:::user
VPNC["VPN concentrator<br/>(one auth gate)"]:::vpn
ALL["All internal resources<br/>now reachable from that IP"]:::vpn
U1 -->|"login once"| VPNC
VPNC -->|"internal IP assigned → full network"| ALL
end
subgraph ZT_MODEL["Zero Trust model"]
U2["User"]:::user
IAP1["Identity-Aware Proxy<br/>(checks every request)"]:::ztna
APP2["Dashboard app<br/>(authorized)"]:::app
DB2["Database admin<br/>(not authorized → invisible)"]:::db
U2 -->|"request to dashboard"| IAP1
IAP1 -->|"identity ✓ device ✓ policy ✓ → forward"| APP2
IAP1 -.->|"no route — user never sees this"| DB2
end
| VPN | ZTNA | |
|---|---|---|
| Trust scope | Network-wide once inside | Per-resource, per-request |
| Auth frequency | Once at VPN login | Every request |
| Lateral movement | Easy — full subnet access | Hard — each resource requires its own authorization |
| Visibility to attacker | All internal IPs reachable | Only the authorized resource is reachable |
| Client experience | VPN software, tunnel, routing conflicts | Browser or lightweight agent, often transparent |
| App changes required | None — apps see VPN IPs as normal | Trust injected headers; verify signed JWT |
| Best for | Legacy TCP apps, L3 access requirements | Web apps, APIs, internal tools, cloud-native |
A session token is stolen from a ZTNA-protected internal tool. What can the attacker do with it compared to a stolen VPN credential?
4. BeyondCorp — The Origin
Google published BeyondCorp in 2014 after Operation Aurora demonstrated that perimeter security couldn't stop a compromised internal machine. The core insight:
Move access controls from the network perimeter to individual devices and users.
Three components BeyondCorp identified as necessary:
sequenceDiagram
participant EMP as Employee (any network)
participant PROXY as Access Proxy (BeyondCorp)
participant AUTH as Auth + Device DB
participant APP as Internal App (no public IP)
EMP->>PROXY: GET https://app.corp.example.com/dashboard
PROXY->>AUTH: Who is this? (OIDC token, device cert)
AUTH->>AUTH: identity = alice@corp<br/>device = managed, patched, cert valid<br/>risk = low
AUTH-->>PROXY: approved
PROXY->>PROXY: check ACL: alice allowed for /dashboard?
PROXY->>APP: forward (X-Authenticated-User: alice@corp)
APP-->>PROXY: 200 OK
PROXY-->>EMP: 200 OK
Note over APP: App never sees the original external request.<br/>It trusts headers injected by the proxy — which only the proxy can set,<br/>because the app has no other network path.
In the BeyondCorp model, a developer's laptop is company-issued but hasn't received a security patch for 30 days. They have valid credentials. Are they granted access?
5. GCP Implementation: Identity-Aware Proxy (IAP)
GCP's IAP is Google's managed BeyondCorp-style proxy. Put your app behind IAP and only authenticated, authorized Google identities can reach it — no VPN required.
graph TD
classDef ext fill:#34495e,stroke:#212f3c,color:#fff
classDef iap fill:#8e44ad,stroke:#6c3483,color:#fff
classDef google fill:#4285f4,stroke:#2a56c6,color:#fff
classDef app fill:#27ae60,stroke:#1e8449,color:#fff
classDef blocked fill:#e74c3c,stroke:#c0392b,color:#fff
USER["User (any network)"]:::ext
subgraph GCP["GCP"]
LB["Cloud Load Balancer<br/>(IAP attaches here — all traffic enters here)"]:::iap
IAP["Cloud IAP<br/>HTTPS termination + identity check"]:::iap
OAUTH["Google OAuth 2.0 / OIDC"]:::google
POLICY["IAM binding<br/>roles/iap.httpsResourceAccessor"]:::google
APP["Cloud Run / App Engine / GCE<br/>(firewall: DENY all except Google front-end ranges)"]:::app
end
DIRECT["Direct IP hit<br/>(bypassing IAP)"]:::blocked
USER -->|"1. request"| LB
LB --> IAP
IAP -->|"2. no token → redirect to login"| OAUTH
OAUTH -->|"3. user authenticates"| OAUTH
OAUTH -->|"4. OIDC token returned"| IAP
IAP -->|"5. check IAM policy"| POLICY
POLICY -->|"6. authorized → forward + inject headers"| APP
DIRECT -.->|"GCP firewall: blocked — no direct path exists"| APP
What your app receives — headers IAP injects that only IAP can set (because the app has no other inbound path):
X-Goog-Authenticated-User-Email: accounts.google.com:alice@corp.com
X-Goog-Authenticated-User-Id: accounts.google.com:123456789
X-Goog-Iap-Jwt-Assertion: <signed JWT — verify this, not the raw email header>
Setup walkthrough:
130.211.0.0/22, 35.191.0.0/16). This makes IAP the only path — if someone tries to hit the VM's IP directly, the firewall drops it before it gets anywhere near the app.
gcloud compute backend-services update my-backend --iap=enabled,oauth2-client-id=...,oauth2-client-secret=.... IAP creates an OAuth 2.0 client that redirects unauthenticated users to Google login. Authenticated users receive a short-lived OIDC token that IAP validates on every subsequent request.
roles/iap.httpsResourceAccessor binding on the backend service. Grant it to individuals, groups, or domains. The backend service itself becomes the access boundary — different backends can have different IAM policies.
X-Goog-Iap-Jwt-Assertion instead: it's a signed JWT only Google's private key can produce. Any request with a forged header but no valid JWT should be rejected at the app layer as a defense-in-depth measure.
# Step 1: Restrict ingress to Google front-end IPs only
gcloud compute firewall-rules create allow-google-lb-only \
--network=my-vpc \
--action=ALLOW \
--rules=tcp:8080 \
--source-ranges=130.211.0.0/22,35.191.0.0/16 \
--target-tags=app-server
# Step 2: Enable IAP on the backend service (OAuth client already created in Console)
gcloud compute backend-services update my-backend-service \
--global \
--iap=enabled,oauth2-client-id=CLIENT_ID.apps.googleusercontent.com,oauth2-client-secret=SECRET
# Step 3: Grant a user access
gcloud iap web add-iam-policy-binding \
--resource-type=backend-services \
--service=my-backend-service \
--member=user:alice@corp.com \
--role=roles/iap.httpsResourceAccessor
# Step 4: Grant a whole Google Group access
gcloud iap web add-iam-policy-binding \
--resource-type=backend-services \
--service=my-backend-service \
--member=group:engineers@corp.com \
--role=roles/iap.httpsResourceAccessor
# Verify who currently has access
gcloud iap web get-iam-policy \
--resource-type=backend-services \
--service=my-backend-service
Verifying the JWT in Go (defense-in-depth):
import (
"context"
"fmt"
"net/http"
"google.golang.org/api/idtoken"
)
// audience format:
// App Engine: /projects/<project-number>/apps/<project-id>
// HTTPS LB backend: /projects/<project-number>/global/backendServices/<backend-id>
func verifyIAP(r *http.Request, audience string) (email string, err error) {
jwt := r.Header.Get("X-Goog-Iap-Jwt-Assertion")
if jwt == "" {
return "", fmt.Errorf("missing IAP JWT — request did not come through IAP")
}
payload, err := idtoken.Validate(context.Background(), jwt, audience)
if err != nil {
return "", fmt.Errorf("invalid IAP JWT: %w", err)
}
email, _ = payload.Claims["email"].(string)
return email, nil
}
A developer curl-s your IAP-protected endpoint and manually sets X-Goog-Authenticated-User-Email: admin@corp.com. They skip the IAP auth flow entirely. Does this succeed?
6. ZTNA for Service-to-Service (Not Just Humans)
Zero Trust isn't only for human → app traffic. Service-to-service calls inside a cluster also need per-request identity — this is exactly where mTLS fits.
POST /v1/charge freely. Network policies help but are coarse; there's no per-service identity, only IP ranges.
orders service account is allowed to call POST /v1/charge. A compromised pod with a different service account gets a 403 at the sidecar — before any app code runs.
graph LR
classDef svc fill:#2980b9,stroke:#1f618d,color:#fff
classDef mesh fill:#8e44ad,stroke:#6c3483,color:#fff
classDef policy fill:#e67e22,stroke:#ba6018,color:#fff
classDef blocked fill:#e74c3c,stroke:#c0392b,color:#fff
subgraph K8S["Kubernetes cluster — Istio service mesh"]
ORDERS["orders-svc<br/>SA: orders"]:::svc
PAYMENTS["payments-svc<br/>SA: payments"]:::svc
INVENTORY["inventory-svc<br/>SA: inventory"]:::svc
COMPROMISED["compromised-pod<br/>SA: frontend (wrong identity)"]:::blocked
POLICY_ENG["Istio control plane<br/>AuthorizationPolicy"]:::policy
ORDERS -->|"mTLS cert: orders → allowed POST /v1/charge"| PAYMENTS
COMPROMISED -.->|"mTLS cert: frontend → 403 DENIED"| PAYMENTS
PAYMENTS -.->|"no AuthorizationPolicy match → blocked"| INVENTORY
POLICY_ENG -.->|"distributes policies to sidecars"| PAYMENTS
end
The AuthorizationPolicy that enforces this:
apiVersion: security.istio.io/v1beta1
kind: AuthorizationPolicy
metadata:
name: payments-allow-orders-only
namespace: default
spec:
selector:
matchLabels:
app: payments
action: ALLOW
rules:
- from:
- source:
# mTLS identity derived from the ServiceAccount cert — not IP, not label
principals:
- "cluster.local/ns/default/sa/orders"
to:
- operation:
methods: ["POST"]
paths: ["/v1/charge", "/v1/refund"]
spiffe://cluster.local/ns/<namespace>/sa/<serviceaccount> — encoding the pod's Kubernetes identity, not its IP.
orders pod thinks it's making a plain HTTP call; its sidecar is actually presenting an mTLS certificate with its SPIFFE identity.
orders, or the path/method don't match, the sidecar returns 403 before forwarding to the payments app.
sa/frontend or whatever it runs as). The payments sidecar sees a principal that isn't in the ALLOW list and blocks it — without any change to the payments application itself.
A pod running in the same Kubernetes namespace as the payments service is compromised. It calls the payments ClusterIP directly on port 8080. What stops it?
7. How ZTNA, mTLS, and PSC Compose
These three tools aren't alternatives — they're layers of the same architecture, each blocking a different attacker path:
graph TD
classDef human fill:#34495e,stroke:#212f3c,color:#fff
classDef ext_svc fill:#2980b9,stroke:#1f618d,color:#fff
classDef internal fill:#27ae60,stroke:#1e8449,color:#fff
classDef iap fill:#8e44ad,stroke:#6c3483,color:#fff
classDef psc fill:#9b59b6,stroke:#8e44ad,color:#fff
classDef mtls fill:#e67e22,stroke:#ba6018,color:#fff
classDef app fill:#16a085,stroke:#117a65,color:#fff
HUMAN["Human user<br/>(browser, any network)"]:::human
EXT_SVC["Partner / external service<br/>(crosses VPC boundary)"]:::ext_svc
INT_SVC["Internal microservice<br/>(same cluster)"]:::internal
IAP["Layer 1 — IAP<br/>human identity + device posture + IAM policy"]:::iap
PSC["Layer 2 — PSC endpoint<br/>network isolation: only approved VPCs reach this surface"]:::psc
MTLS["Layer 3 — mTLS + AuthorizationPolicy<br/>service identity: which service, which path, which method"]:::mtls
APP["Protected service<br/>(no public IP, no direct route)"]:::app
HUMAN -->|"browser request"| IAP
IAP -->|"identity verified"| MTLS
EXT_SVC -->|"VPC boundary"| PSC
PSC -->|"network path approved"| MTLS
INT_SVC -->|"cluster-internal call"| MTLS
MTLS -->|"all checks pass"| APP
| Layer | Tool | What it verifies | Blocks |
|---|---|---|---|
| Network isolation | PSC / firewall | Only approved VPCs/IPs can connect | Internet, unapproved VPCs |
| Human identity | IAP (BeyondCorp) | Who the human is, device health, IAM role | Stolen VPN creds, unauthorized employees |
| Service identity | mTLS + Istio AuthPolicy | Which service calls which path/method | Compromised pods, lateral movement, over-privileged services |
Your payments service has all three layers. A pod inside the cluster is compromised — same namespace, same cluster. PSC doesn't apply (same cluster). IAP doesn't apply (not human). What stops it?
8. Debugging
# ── IAP ──────────────────────────────────────────────────────────────────
# Who currently has IAP access to a backend?
gcloud iap web get-iam-policy \
--resource-type=backend-services \
--service=my-backend-service
# Test if YOUR token can reach an IAP-protected endpoint
TOKEN=$(gcloud auth print-identity-token)
curl -H "Authorization: Bearer $TOKEN" https://app.corp.example.com/
# Decode the JWT the app receives (useful for seeing exact claims)
# Paste the X-Goog-Iap-Jwt-Assertion value at jwt.io
# ── Istio mTLS ───────────────────────────────────────────────────────────
# Check what mTLS certificates a pod currently holds
istioctl proxy-config secret <pod-name> -n <namespace>
# Check if mTLS is enforced end-to-end between two services
istioctl authn tls-check <pod-name>.<namespace> payments.<namespace>.svc.cluster.local
# See who got 403 DENIED in the payments sidecar access log
kubectl logs <payments-pod> -c istio-proxy | grep '"response_code":"403"'
# Check which AuthorizationPolicies apply to a pod
istioctl x authz check <pod-name> -n <namespace>
# ── PSC ──────────────────────────────────────────────────────────────────
# Check a PSC endpoint's connection status (consumer side)
gcloud compute forwarding-rules describe my-psc-endpoint \
--region=us-central1 \
--format="table(name,IPAddress,pscConnectionStatus)"
# Check Service Attachment for pending/active consumer connections (producer side)
gcloud compute service-attachments describe my-service-attachment \
--region=us-central1 \
--format="table(connectedEndpoints[].status,connectedEndpoints[].endpoint)"
Quick Reference
| Concept | What it does | GCP tool | Alternatives |
|---|---|---|---|
| Identity-Aware Proxy | Per-request human auth, no VPN | Cloud IAP | Cloudflare Access, Tailscale, Pomerium |
| Service identity | Per-request machine auth (mTLS cert) | Istio / Workload Identity | Linkerd, SPIFFE/SPIRE |
| Network isolation | Surgical single-service exposure | PSC | AWS PrivateLink |
| Device trust | Managed + healthy endpoint check | BeyondCorp Enterprise | Jamf + Okta Device Trust |
| Policy engine | Unified allow/deny decisions | IAM + IAP conditions + AuthorizationPolicy | OPA/Gatekeeper |