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:
- Backend is not publicly accessible
- Backend only accepts traffic from IAP
- 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
JWT Verification (Recommended)
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
- GCP IAP Docs: https://cloud.google.com/iap/docs
- Pomerium Docs: https://www.pomerium.com/docs
- OAuth2-Proxy: https://oauth2-proxy.github.io/oauth2-proxy
- BeyondCorp Whitepaper: https://research.google/pubs/pub43231/
- Zero Trust Architecture (NIST): https://csrc.nist.gov/publications/detail/sp/800-207/final