Cloud IAP: Ditch the VPN and Lock Down Your Apps with Google’s Identity-Aware Proxy

You spun up an internal tool. Maybe it’s a Grafana dashboard, a Jupyter notebook, or an ops panel. You need your team to access it, but you don’t want it open to the internet. The classic answer is a VPN: everyone installs a client, you manage keys, some developer is always "can’t connect from home," and suddenly you’re running IT support.

There’s a better way. Google Cloud IAP (Identity-Aware Proxy) sits in front of your application and checks Google identity before a single packet reaches your code. No VPN client, no network tunnels to babysit, no SSH bastion hosts. If the user isn’t authenticated and authorized, they get a 403 before your app even wakes up.

This is the zero-trust model in practice — trust the identity, not the network. Let me show you exactly how it works and how to set it up properly.


What IAP Actually Does

The mental model is simple: IAP is a smart reverse proxy managed by Google. Every request to your application first hits Google’s infrastructure, which validates the requester’s Google identity (Google account or service account) and checks whether that identity has the roles/iap.httpsResourceAccessor IAM role on your resource. Pass both checks — request goes through. Fail either — you get a 403.

What makes it different from just slapping nginx basic auth in front of something:

  • It uses OAuth 2.0 + Google’s identity system. No passwords to leak, no session management to implement.
  • It works with Google Workspace. You can restrict access to @yourcompany.com accounts trivially.
  • It injects verified headers (X-Goog-Authenticated-User-Email, X-Goog-Authenticated-User-Id) that your app can trust — you get identity-aware request context for free.
  • It covers multiple backends — Cloud Run, GCE, GKE (via Ingress), App Engine, even on-prem apps via IAP connector.

The official repo and SDK tooling: https://github.com/googleapis/google-cloud-python — though for IAP specifically you’ll mostly be working with gcloud CLI and the GCP console.


Architecture: Where IAP Sits

For HTTPS resources, IAP is enforced at the Google Cloud HTTPS Load Balancer layer. The flow:

Browser → Google Frontend → HTTPS LB → IAP check → Backend (Cloud Run / GCE / NEG)

For SSH/TCP tunneling (IAP Tunnel), it’s different — IAP acts as a TCP proxy and the traffic flows through Google’s infrastructure without touching a load balancer.

Both modes rely on Google’s identity infrastructure. You never manage tokens or session state.


Setting Up IAP for a Cloud Run Service

This is the most common modern use case. You’ve got a Cloud Run service with --no-allow-unauthenticated and you want your team to reach it from a browser.

Step 1: Create an HTTPS Load Balancer with Serverless NEG

Cloud Run doesn’t plug into IAP directly — you need an external HTTPS Load Balancer fronting a Serverless Network Endpoint Group (NEG).

# Create a serverless NEG pointing at your Cloud Run service
gcloud compute network-endpoint-groups create my-app-neg \
  --region=europe-west1 \
  --network-endpoint-type=serverless \
  --cloud-run-service=my-app

# Create a backend service
gcloud compute backend-services create my-app-backend \
  --global \
  --load-balancing-scheme=EXTERNAL_MANAGED

# Attach the NEG
gcloud compute backend-services add-backend my-app-backend \
  --global \
  --network-endpoint-group=my-app-neg \
  --network-endpoint-group-region=europe-west1

# Create URL map, target HTTPS proxy, forwarding rule
gcloud compute url-maps create my-app-urlmap \
  --default-service=my-app-backend

gcloud compute ssl-certificates create my-app-cert \
  --domains=internal-tool.example.com \
  --global

gcloud compute target-https-proxies create my-app-https-proxy \
  --url-map=my-app-urlmap \
  --ssl-certificates=my-app-cert

gcloud compute forwarding-rules create my-app-forwarding-rule \
  --global \
  --target-https-proxy=my-app-https-proxy \
  --ports=443

Step 2: Enable IAP on the Backend Service

# Enable IAP — this creates an OAuth consent screen automatically if you haven't configured one
gcloud iap web enable \
  --resource-type=backend-services \
  --service=my-app-backend

First run, this will prompt you to configure the OAuth consent screen in the console if it hasn’t been done. Go do that — set the app name, support email, and scope to your organization’s domain.

Step 3: Grant Access

# Grant a specific user
gcloud iap web add-iam-policy-binding \
  --resource-type=backend-services \
  --service=my-app-backend \
  --member="user:[email protected]" \
  --role="roles/iap.httpsResourceAccessor"

# Grant an entire Google Workspace domain (the useful one)
gcloud iap web add-iam-policy-binding \
  --resource-type=backend-services \
  --service=my-app-backend \
  --member="domain:yourcompany.com" \
  --role="roles/iap.httpsResourceAccessor"

# Grant a group
gcloud iap web add-iam-policy-binding \
  --resource-type=backend-services \
  --service=my-app-backend \
  --member="group:[email protected]" \
  --role="roles/iap.httpsResourceAccessor"

That’s it. Point your DNS at the load balancer’s IP, wait for the cert to provision, and try hitting the URL from a browser. You’ll get Google’s OAuth consent flow, and then land on your app.


Terraform: The Right Way to Manage This

Clicking through the console once is fine. Doing it for 10 services is a maintenance nightmare. Here’s the Terraform equivalent:

# iap.tf

resource "google_compute_backend_service" "app" {
  name                  = "my-app-backend"
  load_balancing_scheme = "EXTERNAL_MANAGED"
  protocol              = "HTTP"

  # IAP block — flipping enabled = true is all you need
  iap {
    enabled              = true
    oauth2_client_id     = google_iap_client.app.client_id
    oauth2_client_secret = google_iap_client.app.secret
  }

  backend {
    group = google_compute_region_network_endpoint_group.app.id
  }
}

# IAP OAuth client — tied to the brand (consent screen)
resource "google_iap_client" "app" {
  display_name = "My App IAP Client"
  brand        = "projects/${var.project_number}/brands/${var.project_number}"
}

# Grant access to the engineering group
resource "google_iap_web_backend_service_iam_member" "engineering" {
  project         = var.project_id
  web_backend_service = google_compute_backend_service.app.name
  role            = "roles/iap.httpsResourceAccessor"
  member          = "group:[email protected]"
}

One gotcha with Terraform: google_iap_client requires a brand, and brand creation has historically been a manual step in the console. You can import an existing brand, but you can’t create it via Terraform unless you’re using a newer provider version. Check provider docs before assuming it’ll work end-to-end.


IAP Tunnel: SSH Without a Bastion

This is where IAP gets genuinely elegant. You have a GCE instance with no external IP, no bastion, firewall rules blocking everything — and you can still SSH into it.

# SSH via IAP tunnel — no external IP required on the instance
gcloud compute ssh my-instance \
  --zone=europe-west1-b \
  --tunnel-through-iap

# Open a TCP tunnel to port 5432 (PostgreSQL, for example)
gcloud compute start-iap-tunnel my-instance 5432 \
  --local-host-port=localhost:5432 \
  --zone=europe-west1-b

The firewall rule you need is surprisingly narrow:

# Allow IAP's IP range to reach SSH on your instances
gcloud compute firewall-rules create allow-iap-ssh \
  --network=default \
  --allow=tcp:22 \
  --source-ranges=35.235.240.0/20 \
  --description="Allow IAP TCP tunneling for SSH"

35.235.240.0/20 is Google’s IAP tunnel source range — that’s the only thing that needs to reach port 22. Your entire instance can otherwise sit behind a "deny all ingress" rule.

For PostgreSQL or any TCP port, same pattern — just swap 22 for your target port. You can run psql -h localhost -p 5432 locally and it pipes through IAP’s encrypted tunnel to the remote instance.


Using IAP Headers in Your Application

Once IAP is in front of your app, you get two request headers you can trust:

  • X-Goog-Authenticated-User-Email: accounts.google.com:[email protected]
  • X-Goog-Authenticated-User-Id: accounts.google.com:1234567890

Extract these in your app to know who’s making the request without any additional auth logic:

from flask import Flask, request

app = Flask(__name__)

@app.route("/")
def index():
    # IAP strips and rewrites these — you can trust them
    email = request.headers.get("X-Goog-Authenticated-User-Email", "")
    # Strip the prefix
    user = email.replace("accounts.google.com:", "")
    return f"Hello, {user}"

For stronger verification (when you’re paranoid and you should be), validate the X-Goog-IAP-JWT-Assertion header — a signed JWT you can verify against Google’s public keys:

from google.auth.transport import requests as google_requests
from google.oauth2 import id_token

def verify_iap_jwt(iap_jwt, expected_audience):
    """
    expected_audience format:
    /projects/PROJECT_NUMBER/apps/PROJECT_ID  (App Engine)
    /projects/PROJECT_NUMBER/global/backendServices/SERVICE_ID  (GCE/GKE/Cloud Run)
    """
    return id_token.verify_token(
        iap_jwt,
        google_requests.Request(),
        audience=expected_audience,
        certs_url="https://www.gstatic.com/iap/verify/public_key"
    )

Programmatic Access (Service-to-Service)

When a service account needs to call an IAP-protected endpoint, the browser OAuth flow obviously doesn’t apply. You need to get an OIDC token with the IAP client ID as the audience.

import google.auth.transport.requests
import google.oauth2.id_token
import requests

IAP_CLIENT_ID = "your-iap-oauth-client-id.apps.googleusercontent.com"
TARGET_URL = "https://internal-tool.example.com/api/data"

def make_iap_request(url, client_id):
    auth_req = google.auth.transport.requests.Request()
    token = google.oauth2.id_token.fetch_id_token(auth_req, client_id)
    
    headers = {"Authorization": f"Bearer {token}"}
    response = requests.get(url, headers=headers)
    response.raise_for_status()
    return response.json()

result = make_iap_request(TARGET_URL, IAP_CLIENT_ID)

The service account running this code needs roles/iap.httpsResourceAccessor just like a human user. Don’t forget that — it’s a common oversight when setting up CI pipelines or internal cron jobs that call IAP-protected APIs.


Gotchas

1. IAP doesn’t protect direct-to-backend traffic.

This is the biggest one. If your Cloud Run service has --allow-unauthenticated or your GCE instance has a direct external IP with port 80 open, IAP is decorative. IAP protects the load balancer path only. Always:

  • Set Cloud Run to --no-allow-unauthenticated and only allow the LB’s service account to invoke it
  • Remove external IPs from GCE instances meant to be IAP-protected
  • Lock down firewall rules to block direct access
# Ensure Cloud Run only accepts requests from the LB's service account
gcloud run services add-iam-policy-binding my-app \
  --region=europe-west1 \
  --member="serviceAccount:service-PROJECT_NUMBER@serverless-robot-prod.iam.gserviceaccount.com" \
  --role="roles/run.invoker"

2. The OAuth consent screen must be "Internal" for workspace orgs.

If your org uses Google Workspace, set the consent screen to "Internal" — this restricts OAuth to accounts within your org and skips the verification process Google requires for external apps. Missing this and setting it "External" means random Google accounts could potentially request access.

3. JWT assertion audience is backend-specific, not universal.

The X-Goog-IAP-JWT-Assertion audience differs between App Engine, GCE backend services, and Cloud Run. Get it wrong and JWT validation fails. Find your exact audience:

gcloud compute backend-services describe my-app-backend --global \
  --format="value(id)"
# Then audience is: /projects/PROJECT_NUMBER/global/backendServices/SERVICE_ID

4. Load balancer provisioning takes time.

Managed SSL cert provisioning can take 30-60 minutes. DNS must be pointed at the LB’s IP before certs provision. Don’t panic if it’s broken for an hour after setup — it’s almost always the cert still provisioning.

5. IAP Tunnel needs the iap.tunnelInstances.accessViaIAP permission.

The predefined roles/iap.tunnelResourceAccessor covers this. If you’re granting custom roles, don’t forget this permission or gcloud compute ssh --tunnel-through-iap silently fails with a confusing error.

6. Re-authentication intervals.

By default, IAP re-authenticates users every hour. For long-running browser sessions (dashboards people leave open overnight), this means a redirect to Google mid-session. You can extend the re-auth period in IAP settings, or configure programmatic refresh — but be aware it exists so you don’t debug "random logouts."


Production Patterns

Use Access Levels for context-aware access. Beyond just "who are you," IAP integrates with Access Context Manager to enforce device policy: only allow access from corporate-managed devices, specific IP ranges, or devices with screen-lock enabled. This is the actual zero-trust play.

# Create an access level requiring corp-managed device
gcloud access-context-manager levels create corp-device \
  --title="Corporate Device" \
  --basic-level-spec=device_policy.yaml \
  --policy=POLICY_ID

Separate IAP clients per environment. Don’t reuse the same OAuth client for staging and production. Different backends, different IAM bindings, separate audit trails. The 5 minutes to create a second client saves significant incident investigation time.

Log everything. IAP auth decisions are logged to Cloud Audit Logs. Enable cloudaudit.googleapis.com/activity and ship them to a log sink. You want a record of who accessed what internal tool when — especially if you’re subject to compliance requirements.

Use Workload Identity for service-to-service calls. If your service running on GKE or Cloud Run needs to call another IAP-protected service, use Workload Identity Federation instead of downloading service account JSON keys. Keys get leaked in Docker images and git repos. Workload Identity doesn’t.

Health check bypass. Load balancer health checks come from Google’s probers and don’t carry IAP credentials. If your app health check endpoint is behind IAP, health checks will fail with 403. Either use a dedicated /healthz path and configure the LB health check to hit that, or ensure your app returns 200 for unauthed health check requests from the known GCP health check IP ranges (130.211.0.0/22, 35.191.0.0/16).


When NOT to Use IAP

IAP is great for internal tools, dashboards, and admin panels. It’s the wrong tool for:

  • Public-facing APIs where you need OAuth on your own terms — use Firebase Auth or your own identity layer.
  • Machine-to-machine APIs with non-Google clients — if the caller can’t get a Google OIDC token, IAP just blocks them.
  • Ultra-low-latency internal services — IAP adds a token validation round-trip. For microsecond-sensitive service mesh traffic, mTLS with service identities is more appropriate.
  • Self-hosted / on-prem only environments — IAP is a Google Cloud product. If your infra never touches GCP, look at Pomerium or Authelia for similar zero-trust proxy patterns.

The Actual Value Proposition

The reason IAP is worth the setup complexity: you get zero-trust access control enforced outside your application code, with Google’s identity infrastructure handling the hard parts. Your app doesn’t need to know about auth — it reads a trusted header.

Compare that to the alternative: running and patching a VPN server, managing certificates, handling split-tunnel nightmares, and writing "please restart the VPN client" in your runbooks. For GCP workloads, IAP is strictly better for human-facing internal access.

The gotchas are real — particularly the direct-bypass risk — but they’re all fixable with disciplined infrastructure hygiene. Lock down your backends, use Terraform to keep IAP bindings as code, and audit your logs. You end up with an access model that’s auditable, scalable, and doesn’t require a single VPN client install.

Leave a comment

👁 Views: 9,218 · Unique visitors: 14,506