Skip to content
Back to blog Identity Aware Proxy: Zero Trust Access for Internal Applications

Identity Aware Proxy: Zero Trust Access for Internal Applications

SecurityPlatform Engineering

Identity Aware Proxy: Zero Trust Access for Internal Applications

VPNs are dead. Well, not dead - but they’re the wrong tool for application-level access control. Identity Aware Proxies (IAP) provide a better model: authenticate users at the application layer, not the network layer.

This guide covers what IAP is, why it matters, and how to implement it using GCP IAP, Pomerium, and OAuth2-Proxy.

TL;DR

  • IAP authenticates users before they reach your application
  • No VPN required - works over public internet
  • Integrates with your existing IdP (Google, Okta, Azure AD)
  • Per-application access policies
  • Full Terraform + Kubernetes examples included

What is an Identity Aware Proxy?

An Identity Aware Proxy sits in front of your application and handles authentication before any request reaches your backend. Users authenticate via OAuth2/OIDC, and the proxy validates their identity and authorization before forwarding requests.

                    ┌─────────────────────────────────────────┐
                    │           Identity Provider             │
                    │        (Google, Okta, Azure AD)         │
                    └─────────────────────────────────────────┘

                                        │ OAuth2/OIDC

┌──────────┐     ┌─────────────────────────────────────┐     ┌─────────────┐
│   User   │────▶│        Identity Aware Proxy         │────▶│    App      │
│ Browser  │     │   (Validates identity + policy)     │     │  Backend    │
└──────────┘     └─────────────────────────────────────┘     └─────────────┘


                    X-Forwarded-User: user@company.com
                    X-Forwarded-Email: user@company.com
                    X-Forwarded-Groups: engineering,admin

Why Not Just Use a VPN?

VPNs provide network-level access. Once you’re on the network, you can access everything. This violates zero trust principles.

APPROACH        SCOPE           GRANULARITY     VISIBILITY
========        =====           ===========     ==========
VPN             Network         Broad           Limited
IAP             Application     Per-app         Full audit

With IAP:

  • Each application has its own access policy
  • Users only access what they’re authorized for
  • Every request is logged with user identity
  • No network-level access required

IAP Solutions Compared

SOLUTION          TYPE            COST            BEST FOR
========          ====            ====            ========
GCP IAP           Managed         Per-user        GCP workloads
AWS Cognito+ALB   Managed         Per-MAU         AWS workloads
Pomerium          Self-hosted     Free/Enterprise Multi-cloud, K8s
OAuth2-Proxy      Self-hosted     Free            Simple setups
Cloudflare Access Managed         Per-seat        Edge-first

Architecture Deep Dive

The authentication flow follows standard OAuth2/OIDC:

1. User requests protected resource
   Browser ──▶ IAP ──▶ "Not authenticated"

2. IAP redirects to IdP login
   Browser ──▶ IdP ──▶ "Login page"

3. User authenticates with IdP
   Browser ──▶ IdP ──▶ "Success, here's auth code"

4. IAP exchanges code for tokens
   IAP ──▶ IdP ──▶ "Here's ID token + access token"

5. IAP validates tokens and checks policy
   IAP ──▶ Policy Engine ──▶ "User authorized"

6. Request forwarded with identity headers
   IAP ──▶ Backend ──▶ "Here's the request + X-Forwarded-User"

Headers Injected by IAP

Most IAP solutions inject these headers:

HEADER                      VALUE
======                      =====
X-Forwarded-User            user@company.com
X-Forwarded-Email           user@company.com
X-Forwarded-Groups          engineering,platform
X-Forwarded-Access-Token    eyJhbGciOiJSUzI1...
X-Auth-Request-User         user@company.com

Your application can trust these headers because they come from the proxy, not the user. The proxy strips any incoming headers with these names to prevent spoofing.

GCP Identity Aware Proxy

GCP IAP is the most mature managed solution. It integrates with Cloud Load Balancing and provides per-resource access policies.

Terraform Configuration

# Enable IAP API
resource "google_project_service" "iap" {
  service = "iap.googleapis.com"
}

# OAuth consent screen
resource "google_iap_brand" "project_brand" {
  support_email     = "admin@company.com"
  application_title = "Internal Apps"
  project           = var.project_id
}

# OAuth client for IAP
resource "google_iap_client" "project_client" {
  display_name = "IAP Client"
  brand        = google_iap_brand.project_brand.name
}

# Backend service with IAP enabled
resource "google_compute_backend_service" "app" {
  name        = "app-backend"
  protocol    = "HTTP"
  timeout_sec = 30

  backend {
    group = google_compute_instance_group_manager.app.instance_group
  }

  iap {
    oauth2_client_id     = google_iap_client.project_client.client_id
    oauth2_client_secret = google_iap_client.project_client.secret
  }

  health_checks = [google_compute_health_check.app.id]
}

# IAP access policy - allow specific users
resource "google_iap_web_backend_service_iam_member" "access" {
  project             = var.project_id
  web_backend_service = google_compute_backend_service.app.name
  role                = "roles/iap.httpsResourceAccessor"
  member              = "user:developer@company.com"
}

# Allow entire group
resource "google_iap_web_backend_service_iam_member" "group_access" {
  project             = var.project_id
  web_backend_service = google_compute_backend_service.app.name
  role                = "roles/iap.httpsResourceAccessor"
  member              = "group:engineering@company.com"
}

Verifying IAP Headers in Your App

GCP IAP uses a signed JWT. Verify it in your application:

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

def verify_iap_jwt(iap_jwt, expected_audience):
    """Verify the IAP JWT and return the user's email."""
    try:
        decoded_jwt = id_token.verify_token(
            iap_jwt,
            requests.Request(),
            audience=expected_audience,
            certs_url="https://www.gstatic.com/iap/verify/public_key"
        )
        return decoded_jwt['email']
    except Exception as e:
        print(f"JWT verification failed: {e}")
        return None

# In your Flask/FastAPI app
@app.route('/api/data')
def get_data():
    iap_jwt = request.headers.get('X-Goog-IAP-JWT-Assertion')
    email = verify_iap_jwt(iap_jwt, '/projects/PROJECT_NUM/apps/APP_ID')
    
    if not email:
        return "Unauthorized", 401
    
    return f"Hello, {email}"

Pomerium: Self-Hosted IAP for Kubernetes

Pomerium is the best self-hosted option. It’s designed for Kubernetes and supports advanced policies with OPA.

Architecture with Pomerium

                    ┌──────────────────┐
                    │   IdP (Okta)     │
                    └────────┬─────────┘

┌──────────┐     ┌───────────▼──────────┐     ┌─────────────┐
│  User    │────▶│      Pomerium        │────▶│   Backend   │
│          │     │   (Authenticate +    │     │   Service   │
└──────────┘     │    Authorize +       │     └─────────────┘
                 │    Proxy)            │
                 └──────────────────────┘

                 ┌───────────▼──────────┐
                 │    Policy Engine     │
                 │  (Who can access     │
                 │   what routes)       │
                 └──────────────────────┘

Kubernetes Deployment

# pomerium-config.yaml
apiVersion: v1
kind: ConfigMap
metadata:
  name: pomerium-config
  namespace: pomerium
data:
  config.yaml: |
    # Identity Provider configuration
    idp_provider: google
    idp_client_id: ${IDP_CLIENT_ID}
    idp_client_secret: ${IDP_CLIENT_SECRET}
    
    # Authenticate service URL
    authenticate_service_url: https://authenticate.company.com
    
    # Cookie settings
    cookie_secret: ${COOKIE_SECRET}
    cookie_domain: company.com
    
    # Routes and policies
    routes:
      - from: https://grafana.company.com
        to: http://grafana.monitoring.svc.cluster.local:3000
        policy:
          - allow:
              or:
                - email:
                    is: admin@company.com
                - groups:
                    has: platform-team
        
      - from: https://argocd.company.com
        to: http://argocd-server.argocd.svc.cluster.local:443
        tls_skip_verify: true
        policy:
          - allow:
              or:
                - groups:
                    has: engineering
                    
      - from: https://kibana.company.com
        to: http://kibana.logging.svc.cluster.local:5601
        policy:
          - allow:
              or:
                - groups:
                    has: sre
                - groups:
                    has: engineering
        # Preserve original host header
        preserve_host_header: true

Helm Deployment

# values.yaml
config:
  rootDomain: company.com
  generateTLS: false
  existingSecret: pomerium-secrets

authenticate:
  idp:
    provider: google
    clientID: your-client-id.apps.googleusercontent.com
    clientSecret: your-client-secret
    serviceAccount: |
      {
        "type": "service_account",
        ...
      }

ingress:
  enabled: true
  className: nginx
  annotations:
    cert-manager.io/cluster-issuer: letsencrypt-prod
  hosts:
    - authenticate.company.com
    - grafana.company.com
    - argocd.company.com
  tls:
    - secretName: pomerium-tls
      hosts:
        - "*.company.com"
helm repo add pomerium https://helm.pomerium.io
helm upgrade --install pomerium pomerium/pomerium \
  -n pomerium --create-namespace \
  -f values.yaml

Pomerium Policy Language

Pomerium uses a powerful policy language:

routes:
  # Simple email-based access
  - from: https://admin.company.com
    to: http://admin-backend:8080
    policy:
      - allow:
          or:
            - email:
                is: cto@company.com
            - email:
                is: vp-engineering@company.com

  # Group-based with domain restriction
  - from: https://internal.company.com
    to: http://internal-api:8080
    policy:
      - allow:
          and:
            - domain:
                is: company.com
            - groups:
                has: employees

  # Time-based access (only during business hours)
  - from: https://production-db.company.com
    to: http://db-proxy:5432
    policy:
      - allow:
          and:
            - groups:
                has: dba
            - date:
                after: "2024-01-01T09:00:00Z"
                before: "2024-01-01T18:00:00Z"

  # Claims-based (custom IdP attributes)
  - from: https://contractor-portal.company.com
    to: http://contractor-api:8080
    policy:
      - allow:
          and:
            - claim/contract_status:
                is: active
            - claim/department:
                is: engineering

OAuth2-Proxy: Simple and Lightweight

For simpler setups, OAuth2-Proxy is a lightweight alternative. It’s a single binary that handles OAuth2 authentication.

Kubernetes Deployment

# oauth2-proxy-deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: oauth2-proxy
  namespace: auth
spec:
  replicas: 2
  selector:
    matchLabels:
      app: oauth2-proxy
  template:
    metadata:
      labels:
        app: oauth2-proxy
    spec:
      containers:
        - name: oauth2-proxy
          image: quay.io/oauth2-proxy/oauth2-proxy:v7.6.0
          args:
            - --provider=google
            - --email-domain=company.com
            - --upstream=file:///dev/null
            - --http-address=0.0.0.0:4180
            - --cookie-secure=true
            - --cookie-domain=.company.com
            - --whitelist-domain=.company.com
            - --set-xauthrequest=true
            - --pass-access-token=true
            - --pass-user-headers=true
            - --set-authorization-header=true
          env:
            - name: OAUTH2_PROXY_CLIENT_ID
              valueFrom:
                secretKeyRef:
                  name: oauth2-proxy-secrets
                  key: client-id
            - name: OAUTH2_PROXY_CLIENT_SECRET
              valueFrom:
                secretKeyRef:
                  name: oauth2-proxy-secrets
                  key: client-secret
            - name: OAUTH2_PROXY_COOKIE_SECRET
              valueFrom:
                secretKeyRef:
                  name: oauth2-proxy-secrets
                  key: cookie-secret
          ports:
            - containerPort: 4180
          readinessProbe:
            httpGet:
              path: /ping
              port: 4180
            initialDelaySeconds: 5
            periodSeconds: 5
---
apiVersion: v1
kind: Service
metadata:
  name: oauth2-proxy
  namespace: auth
spec:
  selector:
    app: oauth2-proxy
  ports:
    - port: 4180
      targetPort: 4180

NGINX Ingress Integration

OAuth2-Proxy integrates with NGINX Ingress via annotations:

# ingress-with-oauth2.yaml
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: protected-app
  namespace: default
  annotations:
    nginx.ingress.kubernetes.io/auth-url: "https://oauth2.company.com/oauth2/auth"
    nginx.ingress.kubernetes.io/auth-signin: "https://oauth2.company.com/oauth2/start?rd=$escaped_request_uri"
    nginx.ingress.kubernetes.io/auth-response-headers: "X-Auth-Request-User,X-Auth-Request-Email,X-Auth-Request-Groups"
spec:
  ingressClassName: nginx
  tls:
    - hosts:
        - app.company.com
      secretName: app-tls
  rules:
    - host: app.company.com
      http:
        paths:
          - path: /
            pathType: Prefix
            backend:
              service:
                name: app
                port:
                  number: 8080

AWS: ALB with Cognito Authentication

AWS doesn’t have a direct IAP equivalent, but you can achieve similar functionality using ALB with Cognito authentication.

Terraform Configuration

# Cognito User Pool
resource "aws_cognito_user_pool" "main" {
  name = "internal-apps"

  password_policy {
    minimum_length    = 12
    require_lowercase = true
    require_numbers   = true
    require_symbols   = true
    require_uppercase = true
  }

  # Enable federation with corporate IdP
  schema {
    name                = "email"
    attribute_data_type = "String"
    required            = true
  }
}

# User Pool Domain
resource "aws_cognito_user_pool_domain" "main" {
  domain       = "internal-apps-${data.aws_caller_identity.current.account_id}"
  user_pool_id = aws_cognito_user_pool.main.id
}

# App Client
resource "aws_cognito_user_pool_client" "alb" {
  name         = "alb-client"
  user_pool_id = aws_cognito_user_pool.main.id

  generate_secret = true

  allowed_oauth_flows                  = ["code"]
  allowed_oauth_flows_user_pool_client = true
  allowed_oauth_scopes                 = ["openid", "email", "profile"]

  callback_urls = [
    "https://app.company.com/oauth2/idpresponse"
  ]

  supported_identity_providers = ["COGNITO"]
}

# ALB with Authentication
resource "aws_lb_listener_rule" "authenticated" {
  listener_arn = aws_lb_listener.https.arn
  priority     = 100

  action {
    type = "authenticate-cognito"
    authenticate_cognito {
      user_pool_arn       = aws_cognito_user_pool.main.arn
      user_pool_client_id = aws_cognito_user_pool_client.alb.id
      user_pool_domain    = aws_cognito_user_pool_domain.main.domain

      on_unauthenticated_request = "authenticate"
      session_timeout            = 3600
    }
  }

  action {
    type             = "forward"
    target_group_arn = aws_lb_target_group.app.arn
  }

  condition {
    host_header {
      values = ["app.company.com"]
    }
  }
}

Federate with Corporate IdP (Okta)

# SAML Identity Provider
resource "aws_cognito_identity_provider" "okta" {
  user_pool_id  = aws_cognito_user_pool.main.id
  provider_name = "Okta"
  provider_type = "SAML"

  provider_details = {
    MetadataURL           = "https://company.okta.com/app/xxx/sso/saml/metadata"
    IDPSignout            = "true"
    RequestSigningAlgorithm = "rsa-sha256"
  }

  attribute_mapping = {
    email    = "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress"
    name     = "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name"
    username = "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress"
  }
}

# Update client to use Okta
resource "aws_cognito_user_pool_client" "alb_federated" {
  name         = "alb-client-federated"
  user_pool_id = aws_cognito_user_pool.main.id

  generate_secret = true

  allowed_oauth_flows                  = ["code"]
  allowed_oauth_flows_user_pool_client = true
  allowed_oauth_scopes                 = ["openid", "email", "profile"]

  callback_urls = [
    "https://app.company.com/oauth2/idpresponse"
  ]

  supported_identity_providers = ["Okta"]
}

Handling IAP Headers in Your Application

Your backend needs to trust and parse the identity headers.

Go Example

package main

import (
    "log"
    "net/http"
    "strings"
)

type User struct {
    Email  string
    Groups []string
}

func getUserFromHeaders(r *http.Request) *User {
    email := r.Header.Get("X-Forwarded-Email")
    if email == "" {
        email = r.Header.Get("X-Auth-Request-Email")
    }
    
    if email == "" {
        return nil
    }

    groups := r.Header.Get("X-Forwarded-Groups")
    if groups == "" {
        groups = r.Header.Get("X-Auth-Request-Groups")
    }

    return &User{
        Email:  email,
        Groups: strings.Split(groups, ","),
    }
}

func requireAuth(next http.HandlerFunc) http.HandlerFunc {
    return func(w http.ResponseWriter, r *http.Request) {
        user := getUserFromHeaders(r)
        if user == nil {
            http.Error(w, "Unauthorized", http.StatusUnauthorized)
            return
        }
        
        log.Printf("Request from user: %s, groups: %v", user.Email, user.Groups)
        next(w, r)
    }
}

func requireGroup(group string, next http.HandlerFunc) http.HandlerFunc {
    return func(w http.ResponseWriter, r *http.Request) {
        user := getUserFromHeaders(r)
        if user == nil {
            http.Error(w, "Unauthorized", http.StatusUnauthorized)
            return
        }

        for _, g := range user.Groups {
            if g == group {
                next(w, r)
                return
            }
        }

        http.Error(w, "Forbidden: requires group "+group, http.StatusForbidden)
    }
}

func main() {
    http.HandleFunc("/api/public", func(w http.ResponseWriter, r *http.Request) {
        w.Write([]byte("Public endpoint"))
    })

    http.HandleFunc("/api/user", requireAuth(func(w http.ResponseWriter, r *http.Request) {
        user := getUserFromHeaders(r)
        w.Write([]byte("Hello, " + user.Email))
    }))

    http.HandleFunc("/api/admin", requireGroup("admin", func(w http.ResponseWriter, r *http.Request) {
        w.Write([]byte("Admin-only endpoint"))
    }))

    log.Fatal(http.ListenAndServe(":8080", nil))
}

Express.js Example

const express = require('express');
const app = express();

// Middleware to extract user from IAP headers
const iapAuth = (req, res, next) => {
    const email = req.headers['x-forwarded-email'] || 
                  req.headers['x-auth-request-email'];
    
    if (!email) {
        return res.status(401).json({ error: 'Unauthorized' });
    }

    const groups = (req.headers['x-forwarded-groups'] || 
                    req.headers['x-auth-request-groups'] || '')
                    .split(',')
                    .filter(Boolean);

    req.user = { email, groups };
    next();
};

// Middleware to require specific group
const requireGroup = (group) => (req, res, next) => {
    if (!req.user.groups.includes(group)) {
        return res.status(403).json({ 
            error: `Forbidden: requires group ${group}` 
        });
    }
    next();
};

app.get('/api/user', iapAuth, (req, res) => {
    res.json({ 
        message: `Hello, ${req.user.email}`,
        groups: req.user.groups 
    });
});

app.get('/api/admin', iapAuth, requireGroup('admin'), (req, res) => {
    res.json({ message: 'Admin endpoint' });
});

app.listen(8080, () => console.log('Server running on port 8080'));

Security Considerations

Header Spoofing Prevention

Your IAP must strip any incoming headers that match the injected header names. Otherwise, attackers could spoof identity by sending:

curl -H "X-Forwarded-Email: admin@company.com" https://app.company.com

Most IAP solutions handle this automatically. Verify by testing:

# This should NOT result in admin access
curl -H "X-Forwarded-Email: admin@company.com" \
     -H "X-Forwarded-Groups: admin" \
     https://app.company.com/api/admin

Network Security

If your backend is directly accessible (bypassing IAP), attackers can inject headers directly. Ensure:

  1. Backend is not publicly accessible
  2. Backend only accepts traffic from IAP
  3. Use network policies in Kubernetes
# network-policy.yaml
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: backend-only-from-iap
  namespace: default
spec:
  podSelector:
    matchLabels:
      app: backend
  policyTypes:
    - Ingress
  ingress:
    - from:
        - namespaceSelector:
            matchLabels:
              name: pomerium
          podSelector:
            matchLabels:
              app: pomerium

For maximum security, verify the JWT signature instead of trusting headers. GCP IAP provides signed JWTs in X-Goog-IAP-JWT-Assertion.

Pomerium can also sign requests with JWT:

routes:
  - from: https://api.company.com
    to: http://api-backend:8080
    policy:
      - allow:
          groups:
            has: engineering
    # Sign all requests with JWT
    pass_identity_headers: true
    kubernetes_service_account_token: true

Troubleshooting

Redirect loop after login:

Check that your callback URL matches exactly. Include trailing slashes if configured.

Expected: https://app.company.com/oauth2/callback
Got:      https://app.company.com/oauth2/callback/

“Access Denied” after successful login:

User authenticated but failed authorization. Check:

  • User email in allowed list
  • User groups match policy
  • Domain restriction (e.g., email_domain: company.com)

Headers not reaching backend:

Verify header passthrough in ingress:

annotations:
  nginx.ingress.kubernetes.io/auth-response-headers: "X-Auth-Request-User,X-Auth-Request-Email"

Session expired too quickly:

Increase session timeout:

  • GCP IAP: Cannot be changed (1 hour)
  • Pomerium: cookie_expire: 24h
  • OAuth2-Proxy: --cookie-expire=168h

References

======================================== Identity Aware Proxy + Zero Trust

Authenticate at the edge. Trust nothing.

Found this helpful?

Comments