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.

0/0 checks

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.

One-time check at the boundary. The firewall or VPN concentrator checks credentials once at login. After that, the session is trusted — subsequent requests don't re-verify who you are or what you're allowed to do. The implicit assumption: "if you got past the perimeter, you belong here."
Per-request check, every time. Every request — whether from inside the office LAN or a home WiFi — passes through an identity-aware proxy that verifies: who are you (identity), is your device healthy (device posture), does policy allow this specific action (authorization). No session is implicitly trusted; access to one resource grants nothing else.

Three signals checked on every request

1. Identity — who are you? The user or service must present a verifiable identity token: an OIDC JWT from a trusted IdP (Google, Okta, Azure AD) for humans, or an mTLS certificate with a SPIFFE URI for services. Network location — IP address, VPN subnet — is not identity.
2. Device health — is your device trusted? A managed device database checks whether the machine has a valid device certificate, is running an approved OS version, has disk encryption enabled, and has installed recent security patches. A compromised or unmanaged device fails this check even if the identity is valid.
3. Context — does this make sense right now? Risk signals like time of day, geographic location (impossible travel), or behavioral anomaly scoring feed a policy engine. A valid identity on a healthy device still gets blocked if the access pattern looks wrong — e.g., a finance team member suddenly requesting the production database at 3 AM from a new country.
4. Authorization — is this specific action allowed? Even if all three above pass, the policy engine checks: does this identity have the IAM role (or Istio AuthorizationPolicy, or OPA rule) to perform this operation on this resource? Access is granted per operation, not per session. Accessing the dashboard grants nothing to the admin panel.

Under Zero Trust, does a user connecting from the office network get more access than one connecting from home WiFi?


3. VPN vs ZTNA

Authenticate once → get a full network address on the internal subnet. The VPN concentrator verifies credentials, assigns an internal IP, and the device is now a first-class citizen of the internal network. Every server, database, and admin tool is routable from that IP — individual services don't know or re-verify who you are. Lateral movement after compromise requires only that you're on the VPN.
Authenticate per request → get a forwarded connection to one specific resource. No VPN tunnel, no joining the internal network. Every request hits an identity-aware proxy that re-verifies your identity and device health before forwarding to the one application you asked for. Other applications are not just blocked — they're invisible and unreachable. Compromising one session grants nothing to adjacent services.
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:

1. Device inventory service. A continuously updated database of all managed devices — OS version, patch level, disk encryption status, managed certificate presence. Every access request is checked against this database. An unmanaged personal device or an out-of-date laptop fails the device check regardless of who owns it.
2. Identity provider. OIDC/SAML-backed identity (Google Workspace in Google's case) proves who is requesting, not just what network they're on. Users authenticate with their managed identity, receiving a short-lived token that encodes their identity and device posture.
3. Access proxy. The enforcement point — always in the path, never bypassable. Every request to any internal resource goes through this proxy. It checks the identity token + device posture, evaluates the access control policy for the specific resource being requested, and either forwards the request or returns 403. Applications are never directly reachable.
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:

1. Lock down direct access. Configure the app's ingress to only accept traffic from Google's front-end IP ranges (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.
2. Place an HTTPS Load Balancer in front. IAP attaches to an LB backend service, not directly to a VM or Cloud Run service. Create a global HTTPS LB that forwards to your app, then enable IAP on that backend service via the Cloud Console or gcloud.
3. Enable IAP on the backend service. 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.
4. Grant access via IAM. No user can reach the app until they have the 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.
5. Verify the JWT in app code. The email header is plain text — trivially forgeable if anyone ever bypasses IAP. Verify 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.

Services communicate over plain HTTP inside the cluster. Any pod that can reach the payments service's ClusterIP can call any endpoint on it. A compromised pod — say, a dependency with a supply-chain backdoor — can call POST /v1/charge freely. Network policies help but are coarse; there's no per-service identity, only IP ranges.
Every pod has an Envoy sidecar that automatically wraps all traffic in mTLS, using a SPIFFE certificate tied to the pod's Kubernetes ServiceAccount. The payments service's AuthorizationPolicy says only the 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"]
1. Istio issues SPIFFE certificates automatically. The Istio CA issues a short-lived X.509 certificate to every pod's sidecar. The certificate's SAN URI is spiffe://cluster.local/ns/<namespace>/sa/<serviceaccount> — encoding the pod's Kubernetes identity, not its IP.
2. Sidecars automatically wrap all traffic in mTLS. No application code changes — the Envoy sidecar intercepts outbound calls from the pod and incoming requests to the pod, handling the TLS handshake transparently. The orders pod thinks it's making a plain HTTP call; its sidecar is actually presenting an mTLS certificate with its SPIFFE identity.
3. The payments sidecar checks the AuthorizationPolicy. On every inbound request, the payments pod's sidecar extracts the peer certificate's SPIFFE URI and matches it against the AuthorizationPolicy. If the principal isn't orders, or the path/method don't match, the sidecar returns 403 before forwarding to the payments app.
4. The compromised pod's identity gives it away. Even if the compromised pod calls the same ClusterIP and port, its sidecar presents its own certificate (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 1 — PSC (network isolation). Only VPCs that have been explicitly approved and created a PSC endpoint can even reach the service's network surface. External VPCs, the public internet, and unapproved networks are blocked at this layer — they don't get to attempt a connection, let alone authenticate.
Layer 2 — IAP (human identity). For human browser traffic, IAP intercepts every request, redirects unauthenticated users to OIDC login, checks device posture (with BeyondCorp Enterprise), evaluates IAM policy for the specific backend, and injects a signed JWT that the app can trust. A stolen session cookie from user A gives access to nothing else.
Layer 3 — mTLS + AuthorizationPolicy (service identity). For service-to-service traffic that made it through the network layer, each service's Istio sidecar verifies the peer's SPIFFE certificate and matches it against an AuthorizationPolicy. Even a compromised pod with a valid cert gets blocked if its service account isn't in the allow-list for the specific path and method.
What each layer blocks. PSC stops: random internet, unapproved VPCs. IAP stops: unauthenticated humans, unauthorized employees, stolen VPN credentials. mTLS stops: compromised pods, over-privileged services, lateral movement after a service breach. No single layer stops all of these — you need all three for a complete posture.
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