Google IAP (Identity-Aware Proxy)

Cloud & Infrastructure advanced Building with AI ~30 min read

Overview

Traditional network security relies on perimeter-based access control: services behind a VPN or firewall are trusted, and everything outside is untrusted. This model breaks down in cloud-native environments where services are distributed across regions, developers work remotely, and internal tools need to be accessible from anywhere. Google Identity-Aware Proxy (IAP) implements the zero-trust alternative: every request to a protected resource must carry a valid identity token, regardless of its network origin.

IAP sits in front of Google Cloud load balancers and App Engine instances, intercepting every HTTP request before it reaches the backend. It redirects unauthenticated users to Google’s OAuth 2.0 consent flow, verifies their identity against an access control list, and then forwards the authenticated request with a signed JWT in the X-Goog-IAP-JWT-Assertion header. The backend application can trust this JWT without implementing its own authentication flow — Google’s infrastructure handles the login UI, token issuance, and session management.

As an example, consider a SaaS management platform that uses IAP to secure internal admin portals exposing sensitive operations like cluster provisioning, user management, and billing overrides. Rather than building a custom admin authentication system, the platform configures IAP on its internal load balancer and validates the IAP JWT in middleware. This topic covers how IAP works, how to validate its JWTs in a Go backend, and the patterns for integrating IAP into zero-trust architectures.

Core Concepts

Zero-Trust Network Access

Zero-trust security assumes no implicit trust based on network location. Every request must be authenticated and authorized, whether it comes from the corporate office, a developer’s home network, or another service within the same VPC. IAP enforces this at the infrastructure layer: even if an attacker gains access to the internal network, they cannot reach IAP-protected services without a valid Google identity that is on the access control list.

IAP JWT Assertion

When IAP authenticates a user, it adds a signed JWT to the forwarded request in the X-Goog-IAP-JWT-Assertion header. This JWT contains claims including sub (the user’s unique Google ID), email (their email address), aud (the audience, which is the IAP client ID), and iss (the issuer, always https://cloud.google.com/iap). The JWT is signed with Google’s ES256 keys, which the backend fetches from a well-known JWKS endpoint to validate the signature.

Audience Validation

The aud claim in the IAP JWT identifies the specific IAP-protected resource. For GCE/GKE backends, the audience format is /projects/{project_number}/global/backendServices/{service_id}. For App Engine, it is /projects/{project_number}/apps/{project_id}. Validating the audience prevents token reuse attacks: a JWT issued for one IAP-protected service cannot be used to access a different IAP-protected service, even if both are in the same project.

JWKS Key Rotation

Google rotates the signing keys for IAP JWTs periodically. The public keys are published at https://www.gstatic.com/iap/verify/public_key-jwk in JWK Set format. Backend applications must fetch and cache these keys, refreshing them when a JWT’s kid (key ID) header does not match any cached key. Caching with a TTL of 1 hour is standard practice; shorter TTLs increase latency from key fetches, longer TTLs delay recognition of key rotations.

IAP Context Headers

In addition to the JWT, IAP sets convenience headers: X-Goog-Authenticated-User-Email (prefixed with accounts.google.com:) and X-Goog-Authenticated-User-ID. While convenient for logging and display, these headers should never be trusted for authorization decisions because they can be spoofed if a request bypasses the load balancer. Only the cryptographically signed JWT should be used for authentication and authorization logic.

How It Works

The IAP authentication flow involves three parties: the user’s browser, Google’s IAP infrastructure (running on the load balancer), and the backend application. When a user navigates to an IAP-protected URL, the load balancer checks for a valid IAP session cookie. If absent, it redirects to Google’s OAuth 2.0 consent screen. After successful authentication, IAP issues a session cookie and forwards all subsequent requests with the signed JWT header.

JWT Validation Middleware in Go

The backend validates the IAP JWT on every request. This middleware fetches Google’s public keys, verifies the JWT signature, checks the audience and issuer claims, and extracts the user’s email for downstream handlers.

// middleware/iap.go
package middleware

import (
    "context"
    "fmt"
    "net/http"
    "sync"
    "time"

    "github.com/golang-jwt/jwt/v5"
    "github.com/lestrrat-go/jwx/v2/jwk"
)

const (
    iapIssuer = "https://cloud.google.com/iap"
    iapJWKURL = "https://www.gstatic.com/iap/verify/public_key-jwk"
)

type contextKey string

const UserEmailKey contextKey = "user_email"

// IAPAuth returns middleware that validates Google IAP JWT assertions.
func IAPAuth(expectedAudience string) func(http.Handler) http.Handler {
    cache := &keyCache{ttl: 1 * time.Hour}

    return func(next http.Handler) http.Handler {
        return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
            tokenStr := r.Header.Get("X-Goog-IAP-JWT-Assertion")
            if tokenStr == "" {
                http.Error(w, "missing IAP JWT", http.StatusUnauthorized)
                return
            }

            email, err := validateIAPJWT(tokenStr, expectedAudience, cache)
            if err != nil {
                http.Error(w, fmt.Sprintf("invalid IAP JWT: %v", err),
                    http.StatusForbidden)
                return
            }

            ctx := context.WithValue(r.Context(), UserEmailKey, email)
            next.ServeHTTP(w, r.WithContext(ctx))
        })
    }
}

func validateIAPJWT(tokenStr, audience string, cache *keyCache) (string, error) {
    // Parse the token header to get the key ID.
    token, err := jwt.Parse(tokenStr,
        func(token *jwt.Token) (interface{}, error) {
            if _, ok := token.Method.(*jwt.SigningMethodECDSA); !ok {
                return nil, fmt.Errorf("unexpected signing method: %v",
                    token.Header["alg"])
            }

            kid, ok := token.Header["kid"].(string)
            if !ok {
                return nil, fmt.Errorf("missing kid in JWT header")
            }

            return cache.getKey(kid)
        },
        jwt.WithIssuer(iapIssuer),
        jwt.WithAudience(audience),
        jwt.WithLeeway(30*time.Second),
    )
    if err != nil {
        return "", fmt.Errorf("parsing JWT: %w", err)
    }

    claims, ok := token.Claims.(jwt.MapClaims)
    if !ok {
        return "", fmt.Errorf("unexpected claims type")
    }

    email, ok := claims["email"].(string)
    if !ok || email == "" {
        return "", fmt.Errorf("missing email claim")
    }

    return email, nil
}

// keyCache fetches and caches IAP public keys.
type keyCache struct {
    ttl     time.Duration
    mu      sync.RWMutex
    keys    jwk.Set
    fetched time.Time
}

func (c *keyCache) getKey(kid string) (interface{}, error) {
    c.mu.RLock()
    if c.keys != nil && time.Since(c.fetched) < c.ttl {
        if key, found := c.lookupKey(kid); found {
            c.mu.RUnlock()
            return key, nil
        }
    }
    c.mu.RUnlock()

    // Refresh the key set.
    c.mu.Lock()
    defer c.mu.Unlock()

    ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
    defer cancel()

    set, err := jwk.Fetch(ctx, iapJWKURL)
    if err != nil {
        return nil, fmt.Errorf("fetching IAP JWKS: %w", err)
    }
    c.keys = set
    c.fetched = time.Now()

    key, found := c.lookupKey(kid)
    if !found {
        return nil, fmt.Errorf("key %s not found in JWKS", kid)
    }
    return key, nil
}

func (c *keyCache) lookupKey(kid string) (interface{}, bool) {
    key, found := c.keys.LookupKeyID(kid)
    if !found {
        return nil, false
    }
    var raw interface{}
    if err := key.Raw(&raw); err != nil {
        return nil, false
    }
    return raw, true
}

Wiring the Middleware

The IAP middleware is applied to the admin router in the platform. The expected audience is loaded from configuration, which varies by deployment environment since GCE backend service IDs differ between staging and production.

// cmd/platform/main.go
package main

import (
    "net/http"
    "os"

    "github.com/go-chi/chi/v5"
    "github.com/example/platform/middleware"
    "github.com/example/platform/handlers"
)

func main() {
    iapAudience := os.Getenv("IAP_AUDIENCE")

    r := chi.NewRouter()

    // Public routes (health checks, webhooks).
    r.Get("/healthz", handlers.Healthz)

    // Admin routes protected by IAP.
    r.Route("/admin", func(r chi.Router) {
        r.Use(middleware.IAPAuth(iapAudience))
        r.Get("/clusters", handlers.ListClusters)
        r.Post("/clusters/{id}/provision", handlers.ProvisionCluster)
        r.Get("/users", handlers.ListUsers)
        r.Post("/billing/override", handlers.BillingOverride)
    })

    http.ListenAndServe(":8080", r)
}

Extracting User Identity in Handlers

Downstream handlers access the authenticated user’s email from the request context, using it for authorization checks and audit logging.

// handlers/clusters.go
package handlers

import (
    "encoding/json"
    "log"
    "net/http"

    "github.com/example/platform/middleware"
)

func ProvisionCluster(w http.ResponseWriter, r *http.Request) {
    email := r.Context().Value(middleware.UserEmailKey).(string)

    // Check if user is authorized for cluster provisioning.
    if !isClusterAdmin(email) {
        http.Error(w, "insufficient permissions", http.StatusForbidden)
        return
    }

    log.Printf("cluster provisioned by %s", email)

    // ... provisioning logic ...

    w.Header().Set("Content-Type", "application/json")
    json.NewEncoder(w).Encode(map[string]string{
        "status":       "provisioning",
        "requested_by": email,
    })
}

Practical Applications

The platform uses IAP to protect its internal admin dashboard without building any authentication UI. Administrators navigate to https://admin.platform.example.com, are redirected to Google’s login page, and upon successful authentication see the admin dashboard. The entire authentication flow — login page, MFA enforcement, session cookies, token refresh — is handled by Google’s infrastructure. The platform’s backend only needs the 80-line middleware above to participate in the zero-trust model.

IAP is also used for service-to-service authentication within the platform. Internal microservices that need to call the admin API (e.g., an automated cluster scaling service) obtain an OIDC token from their service account’s metadata server and include it in the Authorization header. IAP validates this token against the service account’s identity, applying the same access control policies as for human users. This eliminates the need for API keys or shared secrets between internal services.

For debugging in staging environments, IAP’s access control list can be temporarily expanded to include developer email addresses, giving them direct access to the admin API without VPN tunnels or SSH bastion hosts. Access is audited in Cloud Audit Logs, providing a paper trail of who accessed what and when — a compliance requirement for SOC 2 and similar frameworks.

Typical Integration Points

Implementation Patterns

Defense in Depth: JWT Over Headers

Always validate the cryptographically signed JWT (X-Goog-IAP-JWT-Assertion) rather than trusting the convenience headers (X-Goog-Authenticated-User-Email). If traffic reaches the backend through a path that bypasses the load balancer (e.g., a misconfigured VPC firewall rule, a Kubernetes port-forward, or a debugging proxy), the convenience headers can be set to any value by the caller. The JWT cannot be forged without Google’s private signing key.

Multi-Audience Support

In environments with multiple IAP-protected backend services (e.g., admin API and metrics dashboard), each service has a different audience string. Rather than hardcoding the audience, load it from environment configuration. In Kubernetes, this maps naturally to a ConfigMap or environment variable in the Deployment spec. The middleware accepts the audience as a parameter, making it reusable across services.

Graceful Key Refresh

The JWKS key cache uses a read-write lock to allow concurrent JWT validations while serializing key refreshes. When a JWT arrives with an unknown kid, only one goroutine fetches the new key set while others wait. This prevents a thundering herd of JWKS fetches when Google rotates keys and multiple requests arrive simultaneously with the new key ID.

Testing Without IAP

For local development and testing, the IAP middleware should be bypassable. A common pattern is to check for a DISABLE_IAP environment variable and, when set, skip JWT validation and inject a test email into the context. This must never be enabled in production — enforce it with a startup check that panics if DISABLE_IAP is set and the environment is not development or test.

// middleware/iap.go (development bypass)
func IAPAuth(expectedAudience string) func(http.Handler) http.Handler {
    if os.Getenv("DISABLE_IAP") != "" {
        env := os.Getenv("APP_ENV")
        if env != "development" && env != "test" {
            panic("DISABLE_IAP is set in non-development environment")
        }
        return func(next http.Handler) http.Handler {
            return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
                email := r.Header.Get("X-Test-User-Email")
                if email == "" {
                    email = "dev@example.com"
                }
                ctx := context.WithValue(r.Context(), UserEmailKey, email)
                next.ServeHTTP(w, r.WithContext(ctx))
            })
        }
    }
    // ... production validation logic ...
}

Common Pitfalls

Trusting Convenience Headers for Authorization

The X-Goog-Authenticated-User-Email header is set by IAP but is trivially spoofable if a request reaches the backend without traversing the load balancer. Never use this header for authorization decisions. The JWT in X-Goog-IAP-JWT-Assertion is the only trustworthy source of identity. Using convenience headers for anything beyond logging or display creates a critical security vulnerability.

Hardcoding the JWKS URL

While Google’s IAP JWKS URL has been stable for years, hardcoding it deep in middleware makes it impossible to test with mock signing keys. Accept the JWKS URL as a configuration parameter, defaulting to Google’s production URL. In tests, point it at a local HTTP server that serves test key sets, enabling end-to-end middleware testing without network calls to Google.

Missing Audience Validation

Skipping the aud claim check means a JWT issued for any IAP-protected service in the same GCP project would be accepted. An attacker with access to a lower-sensitivity IAP-protected service could reuse their JWT to access a higher-sensitivity service. Always validate the audience matches the specific backend service ID.

Not Handling Clock Skew

JWTs have exp and iat claims that are validated against the server’s clock. If the backend’s clock drifts (common in VMs without NTP), valid JWTs may be rejected or expired JWTs accepted. The middleware should allow a small leeway (30 seconds is standard) and the server should run NTP to keep its clock synchronized.

Forgetting to Protect Non-HTTP Paths

IAP only protects HTTP(S) traffic through the load balancer. gRPC services, WebSocket connections, and TCP-level access (e.g., direct database connections) are not covered by IAP. These paths need their own authentication mechanisms. A common mistake is assuming that because the HTTP admin API is IAP-protected, all access to the backend is secured.

Further Reading

Further Reading