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.

An HTTPS request through IAP, and the two ways around itbrowser / clientcookie or ID tokenload balanceror Cloud Run / App EngineHTTPSauthenticateGoogle sign-inauthorizeIAM + conditionsbackendverifies x-goog-iap-jwt-assertionsigned JWTbypass: VM reachable from the internet or VPCunsigned headers can be forgeddirectgcloud / IAP DesktopSSH, RDP, any TCP portIAP TCP forwardingtunnelResourceAccessorVM without external IPfirewall allows 35.235.240.0/20WebSocketTCPIAP decides who may reach the app. The app still checks the signed header, and the network must not offer a path that skips IAP.
IAP authenticates and authorizes before the backend sees the request, then attaches a signed JWT. TCP forwarding provides the same identity check for SSH, RDP and other ports.

What it can protect

ResourceHow IAP attachesNotes
Backend services of an Application Load BalancerEnabled per backend serviceVMs, instance groups, GKE through Ingress or Gateway, serverless NEGs; the frontend needs HTTPS
App EngineEnabled on the appAccess can be granted for the whole app or narrower
Cloud RunEnabled 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 TCPTCP forwarding: tunnels from gcloud or IAP DesktopSSH, 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_levels

By 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-b

Windows 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.

QuestionVPN or bastion hostIdentity-Aware Proxy
What grants accessBeing connected to the networkAn IAM role on the individual app or VM
Scope after accessUsually everything routable on the networkOnly the resources the role is bound to
ClientVPN client, or SSH keys to a jump hostA browser for web apps; gcloud or IAP Desktop for TCP
Context checksTypically done once, at connect timeEvaluated on every request through IAM conditions
Audit trailConnection logs, often keyed by IPCloud 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-backend

A 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

  1. List your internal web apps and VMs reachable through VPNs, bastions or external IPs.
  2. Enable IAP on one app and grant roles/iap.httpsResourceAccessor to a group on that resource only.
  3. Add JWT verification with the exact audience for that resource, and exempt only the health-check path.
  4. Restrict the backend firewall to the load balancer ranges or proxy-only subnet, then prove a direct request is rejected.
  5. Decide whether you need a custom OAuth client for external users or programmatic callers before rollout.
  6. Add access levels for sensitive apps and path conditions where roles differ within an app.
  7. Replace bastion hosts with TCP forwarding: remove external IPs, allow 35.235.240.0/20 on required ports, and grant the tunnel role to on-call groups.
Key takeaway: IAP moves the access decision from the network to identity: each request is authenticated with Google sign-in and authorized by IAM, optionally with access levels and host or path conditions, before it reaches the application. Enable it per backend service, App Engine app or Cloud Run service, and use TCP forwarding instead of bastions. It is only as strong as its weakest path, so verify the signed x-goog-iap-jwt-assertion with the exact audience in the application, close network routes that skip IAP, and choose the OAuth client deliberately if code or external users need access.