Most internal tools start behind a VPN: the network decides who is trusted, and anyone inside can reach everything. Identity-Aware Proxy (IAP) replaces that model for applications on Google Cloud. Every request is checked against the caller's identity and, optionally, their device and context before it reaches the application, whether the caller is at the office or on a train.
This article explains what IAP does on each request, which resources it can protect, how IAM roles and conditions decide access, and how to enable it on a load balancer or directly on Cloud Run. It then covers the parts teams most often get wrong: verifying IAP's signed header in the application, closing network paths that skip IAP, calling IAP-protected services from code, and using TCP forwarding instead of bastion hosts. It ends with a worked example and a checklist.
What IAP does on each request
IAP runs inside Google's serving path, in front of your application. For a web request it performs two checks. Authentication: if the browser has no valid IAP session, IAP redirects it to Google sign-in, then sets a session cookie. Authorization: IAP evaluates the IAM policy on the protected resource. The caller needs the IAP-secured Web App User role, roles/iap.httpsResourceAccessor, and any conditions attached to that binding must be true for this request.
If both pass, IAP forwards the request and adds a header, x-goog-iap-jwt-assertion, containing a JSON Web Token signed by Google. It identifies the user and the resource the request was meant for. If either check fails, the request never reaches your code.
What it can protect
| Resource | How IAP attaches | Notes |
|---|---|---|
| Backend services of an Application Load Balancer | Enabled per backend service | VMs, instance groups, GKE through Ingress or Gateway, serverless NEGs; the frontend needs HTTPS |
| App Engine | Enabled on the app | Access can be granted for the whole app or narrower |
| Cloud Run | Enabled directly on the service with --iap (generally available) | Covers every ingress path, including the run.app URL; cannot also be enabled on a load balancer in front of the same service |
| VMs over TCP | TCP forwarding: tunnels from gcloud or IAP Desktop | SSH, RDP or any port; needs roles/iap.tunnelResourceAccessor |
IAP is not a network firewall and not a web application firewall. It decides who may call the application; rate limiting and request filtering belong in Cloud Armor (see Cloud Armor). The load-balancer objects it attaches to are explained in Google Cloud load balancing.
Authorization: roles, conditions and access levels
Access is ordinary IAM. Grant roles/iap.httpsResourceAccessor to groups at the level you want: the project for every IAP resource in it, or a single backend service or Cloud Run service. Prefer groups to individual users so access follows team membership. The IAM model is covered in Google Cloud IAM.
Conditions make a binding more specific. IAP supports three kinds of request attribute. Access levels come from Access Context Manager and describe context such as IP range or device state, and are tested with request.auth.access_levels. Host and path conditions, request.host and request.path, restrict a binding to part of an app. Date and time conditions allow temporary access.
Host and path values are matched exactly as sent, and Google's documentation warns that this is deliberate: an attacker could otherwise use an encoded variant of a path. Write conditions that allow known prefixes rather than ones that deny a single path, and put a leading dot in host suffix checks, as in request.host.endsWith(".example.com").
Enabling IAP
On a load balancer, IAP is a property of the backend service. On Cloud Run it is a property of the service, and the IAP service agent must be allowed to invoke it. In both cases, grant access with gcloud iap web add-iam-policy-binding, choosing the resource type.
# Global external Application Load Balancer backend service
gcloud compute backend-services update admin-backend --global --iap=enabled
# Cloud Run, without a load balancer
gcloud run deploy admin-ui --region=europe-west1 --image=IMAGE_URL \
--no-allow-unauthenticated --iap
gcloud run services add-iam-policy-binding admin-ui --region=europe-west1 \
--member=serviceAccount:service-PROJECT_NUMBER@gcp-sa-iap.iam.gserviceaccount.com \
--role=roles/run.invoker
# Who may use the app: a group, restricted by an access level (see cond.yaml)
gcloud iap web add-iam-policy-binding --resource-type=backend-services \
--service=admin-backend --member=group:ops@example.com \
--role=roles/iap.httpsResourceAccessor --condition-from-file=cond.yaml
# cond.yaml
title: managed-devices-only
expression: >-
"accessPolicies/POLICY_ID/accessLevels/managed_devices" in request.auth.access_levelsBy default IAP uses a Google-managed OAuth client, which limits access to users in your organization and needs no setup. If external users need access, or code must call the app with ID tokens (covered below), configure a custom OAuth client instead.
Verify the signed header in the application
IAP adds the signed JWT for a reason: the application should not trust a request just because it arrived. If anything can reach the backend without passing through IAP, such as a misconfigured firewall, a peered VPC, a debugging port or another load balancer, that request carries whatever headers the caller chose. IAP also sets unsigned headers, x-goog-authenticated-user-email and x-goog-authenticated-user-id, and Google's documentation says plainly that an attacker who bypasses IAP can forge them. Use the signed token.
Verification means checking the signature against IAP's public keys and checking the claims. The algorithm is ES256; keys are published at https://www.gstatic.com/iap/verify/public_key and in JWK form at public_key-jwk. The issuer must be https://cloud.google.com/iap, exp must be in the future and iat in the past, allowing 30 seconds of clock skew. The audience must identify your resource, so a token issued for another IAP app is rejected:
- Backend service:
/projects/PROJECT_NUMBER/global/backendServices/SERVICE_ID - App Engine:
/projects/PROJECT_NUMBER/apps/PROJECT_ID - Cloud Run:
/projects/PROJECT_NUMBER/locations/REGION/services/SERVICE_NAME
from flask import Flask, abort, g, request
from google.auth.transport import requests as google_requests
from google.oauth2 import id_token
# Backend service form; App Engine and Cloud Run use different audience strings.
EXPECTED_AUDIENCE = "/projects/123456789012/global/backendServices/4567890123456789"
IAP_KEYS = "https://www.gstatic.com/iap/verify/public_key"
transport = google_requests.Request() # use a caching session in production
app = Flask(__name__)
@app.before_request
def require_iap():
if request.path == "/healthz": # load balancer health checks carry no JWT
return
assertion = request.headers.get("x-goog-iap-jwt-assertion")
if not assertion:
abort(401)
try:
claims = id_token.verify_token(assertion, transport,
audience=EXPECTED_AUDIENCE, certs_url=IAP_KEYS)
except ValueError: # bad signature, expired, wrong audience
abort(401)
if claims.get("iss") != "https://cloud.google.com/iap":
abort(401)
g.user_id, g.user_email = claims["sub"], claims.get("email")Use sub as the stable user key; email addresses can change. Cache the key set rather than fetching it on every request.
Closing the bypass paths
Verification in the app is the last line; the network should also refuse traffic that did not come through IAP. For VMs behind a global external Application Load Balancer, allow the backend port only from Google's proxy and health-check ranges, 130.211.0.0/22 and 35.191.0.0/16, and give the VMs no external IP. Regional and internal Application Load Balancers send traffic from a proxy-only subnet, so allow that subnet instead.
For Cloud Run with direct IAP, IAP covers every ingress path, including the default URL. For Cloud Run behind a load balancer with IAP on the backend service, set ingress to internal and load balancing, so the run.app URL is not a way around it; ingress settings are explained in Cloud Run in depth. Also check for forgotten paths: a second load balancer on the same backends without IAP, or a peered network that can reach the VMs directly.
Calling IAP-protected services from code
Browsers use a cookie; programs send a Google-signed OIDC ID token in the Authorization: Bearer header, or in Proxy-Authorization if the application uses Authorization for its own credentials. For a service account, the token's audience is the client ID of the OAuth client that IAP uses, and the service account needs roles/iap.httpsResourceAccessor like any user.
import requests
from google.auth.transport.requests import Request
from google.oauth2 import id_token
IAP_CLIENT_ID = "1234-abcd.apps.googleusercontent.com" # the custom OAuth client used by IAP
URL = "https://admin.example.com/api/export"
# Works with the metadata server (VM, GKE, Cloud Run) or a service-account credential file
token = id_token.fetch_id_token(Request(), IAP_CLIENT_ID)
resp = requests.get(URL, headers={"Authorization": f"Bearer {token}"}, timeout=30)
# If the app itself uses Authorization, send the IAP token as "Proxy-Authorization: Bearer ..."
resp.raise_for_status()This path needs a custom OAuth client. With the Google-managed client, which is the default, programmatic access with OAuth-based tokens is not possible because you do not hold that client's credentials. Service accounts can still get in with self-signed JWTs whose aud is the exact URL of the protected resource or the URL with a /* wildcard. Decide which you need before users come to depend on the default.
TCP forwarding: SSH and RDP without bastions
TCP forwarding gives administrators access to VMs with no external IP. gcloud opens a WebSocket to IAP, IAP checks the caller's roles/iap.tunnelResourceAccessor permission, then connects to the VM's internal address from the range 35.235.240.0/20, which the firewall must allow on the ports you need. gcloud compute ssh uses IAP automatically when the VM has no external IP, and --tunnel-through-iap forces it.
# Allow IAP's TCP forwarding range to reach SSH on tagged VMs only
gcloud compute firewall-rules create allow-iap-ssh --network=prod \
--direction=INGRESS --action=allow --rules=tcp:22 \
--source-ranges=35.235.240.0/20 --target-tags=ops-vm
# Grant tunnel use (add a condition to limit it to certain VMs or ports)
gcloud projects add-iam-policy-binding PROJECT_ID \
--member=group:oncall@example.com --role=roles/iap.tunnelResourceAccessor
# SSH through IAP, and a local tunnel to a database port
gcloud compute ssh ops-vm-1 --zone=europe-west1-b --tunnel-through-iap
gcloud compute start-iap-tunnel db-vm-1 5432 --local-host-port=localhost:15432 \
--zone=europe-west1-bWindows administrators use IAP Desktop or start-iap-tunnel on port 3389 and connect Remote Desktop to localhost. Google states that TCP forwarding is not intended for bulk data transfer and may be rate-limited, and idle sessions disconnect after one hour. Move data through storage, not tunnels.
Worked example: an admin console and on-call access
A team runs an internal admin console on a managed instance group behind a global external Application Load Balancer, and two database VMs that on-call engineers sometimes need to reach. Today both sit behind a VPN.
Web app. They enable IAP on the admin-backend service and grant roles/iap.httpsResourceAccessor to group:ops@example.com with a condition requiring the managed_devices access level. A second binding for group:support@example.com adds request.path.startsWith("/tickets/"), so support staff reach only the ticket pages. The app gains the verification middleware, with the audience set to the backend service's numeric ID, and the firewall rule for port 8080 is narrowed to 130.211.0.0/22 and 35.191.0.0/16. A test request sent straight to a VM's internal address from a peered VPC now gets 401.
Batch exporter. A nightly job calls /api/export. Because it needs an ID token, the team moves IAP to a custom OAuth client, grants the job's service account the accessor role, and uses the programmatic snippet.
On-call access. External IPs are removed from the database VMs, the firewall allows 22 and 5432 from 35.235.240.0/20 to the tagged VMs, and group:oncall@example.com gets the tunnel role. Engineers use start-iap-tunnel to reach Postgres from their laptops. With Data Access audit logs enabled for IAP, each connection is recorded with a user identity, and the VPN is removed.
Failure modes and trade-offs
- Trusting headers without verification. Any bypass then becomes full impersonation. Verify the JWT and its audience.
- Health checks and IAP. Load balancer health checks do not pass through IAP and carry no JWT; exempt the health path in the middleware, not the whole app.
- Default OAuth client surprises. The Google-managed client blocks external users and OAuth-based programmatic access. Switching later means reconfiguring IAP and every caller.
- Over-broad bindings. Granting the accessor role at the project level opens every IAP app in the project. Bind per resource for sensitive apps.
- Tunnels as data pipes. TCP forwarding is for interactive access; bulk copies get rate-limited.
- Expecting IAP to stop data exfiltration. IAP guards entry to apps, not calls from inside to Google APIs; that is the job of VPC Service Controls.
- Latency and dependence. IAP adds a sign-in redirect on first visit and makes Google identity a dependency of every login. For most internal tools that trade is worth it; for public consumer apps, IAP is the wrong tool.
What is Identity-Aware Proxy in Google Cloud, and how is it different from a VPN?
Identity-Aware Proxy (IAP) is a Google Cloud service that sits in front of an application or VM and lets a request through only if the caller has signed in with a Google identity (a user, group member or service account) and IAM grants that identity access to the specific resource. It is Google's implementation of the BeyondCorp, or zero-trust, model: being on a particular network earns no trust, and each request is judged on who is asking and, with access levels, from what context. There is no client software for web apps; users open the normal HTTPS URL and sign in.
| Question | VPN or bastion host | Identity-Aware Proxy |
|---|---|---|
| What grants access | Being connected to the network | An IAM role on the individual app or VM |
| Scope after access | Usually everything routable on the network | Only the resources the role is bound to |
| Client | VPN client, or SSH keys to a jump host | A browser for web apps; gcloud or IAP Desktop for TCP |
| Context checks | Typically done once, at connect time | Evaluated on every request through IAM conditions |
| Audit trail | Connection logs, often keyed by IP | Cloud Audit Logs entries keyed by user identity |
To see whether IAP is protecting a backend service, and who it lets in, query both halves of the setup:
# Is IAP switched on for this backend service?
gcloud compute backend-services describe admin-backend --global \
--format="value(iap.enabled)"
# Which identities hold the IAP web role on it, and with which conditions?
gcloud iap web get-iam-policy --resource-type=backend-services \
--service=admin-backendA common source of confusion is that both commands are needed. Enabling IAP with no accessor bindings locks everyone out, and an IAM binding on a backend service where IAP is disabled has no effect, because no request is being checked. Project-level grants of roles/iap.httpsResourceAccessor also apply, so check gcloud projects get-iam-policy as well when someone has access you cannot explain.
What to do next
- List your internal web apps and VMs reachable through VPNs, bastions or external IPs.
- Enable IAP on one app and grant
roles/iap.httpsResourceAccessorto a group on that resource only. - Add JWT verification with the exact audience for that resource, and exempt only the health-check path.
- Restrict the backend firewall to the load balancer ranges or proxy-only subnet, then prove a direct request is rejected.
- Decide whether you need a custom OAuth client for external users or programmatic callers before rollout.
- Add access levels for sensitive apps and path conditions where roles differ within an app.
- Replace bastion hosts with TCP forwarding: remove external IPs, allow
35.235.240.0/20on required ports, and grant the tunnel role to on-call groups.