Identity-Aware Proxy: Replacing VPN with Continuous Identity Verification

Identity-Aware Proxy: Replacing VPN with Continuous Identity Verification

Problem

A VPN gives a user network-layer access to an internal segment. From that point, the application backend typically does very little to distinguish the authenticated VPN session from any other connection arriving on the internal interface. A compromised laptop, a rogue insider, or a stolen VPN credential grants access to everything reachable on that subnet.

An Identity-Aware Proxy (IAP) inverts this model. No direct path from client to backend exists. All traffic passes through the proxy, which performs full application-layer authentication and authorization on every request, every time. Identity — user, group, device state — determines access, not network location. This is the zero-trust access control pattern applied to proxied HTTP traffic.

The security properties:

  • No implicit trust from network position. A request on 10.0.0.0/8 receives the same scrutiny as one arriving from the public internet.
  • Per-request authorization. Token expiry, group membership changes, and device posture changes take effect immediately, not at the next VPN reconnect.
  • Reduced blast radius. An attacker who compromises a backend cannot then pivot to other backends unless they also hold valid credentials for each.
  • Centralized audit trail. Every access decision, including denials, is logged at a single chokepoint.

This article covers the architecture, a self-hosted implementation with Envoy and OAuth2 Proxy, the GCP-managed IAP option for GKE workloads, Pomerium as an alternative, and how to integrate device posture signals.

IAP Architecture

The pattern has three components:

  1. Proxy frontend. Terminates TLS, handles the OIDC/OAuth2 flow, and enforces authentication. Unauthenticated requests are redirected to the identity provider or rejected with 401.
  2. Authorization service. Given the authenticated identity and request context, decides whether to permit or deny. May be embedded in the proxy or run as a separate sidecar.
  3. Backend applications. Receive only pre-authenticated, pre-authorized requests. They trust the headers injected by the proxy (X-Forwarded-User, X-Forwarded-Groups, or similar) and do not implement their own authentication.

Critically: backends must not be reachable from anything other than the proxy. In Kubernetes this means using NetworkPolicy to restrict ingress to the proxy pod’s label. On bare metal, firewall rules bind the backend to a loopback or internal-only interface. If a backend is reachable by circumventing the proxy, the entire model fails.

OAuth2 Proxy as a Self-Hosted IAP Frontend

oauth2-proxy runs in front of an upstream application, handles the OIDC authorization code flow, and injects identity headers into forwarded requests. It is the simplest self-hosted IAP starting point.

Configuration

# oauth2-proxy.cfg
provider = "oidc"
oidc_issuer_url = "https://accounts.example.com"
client_id = "iap-proxy"
client_secret_file = "/run/secrets/oauth2-proxy-client-secret"

redirect_url = "https://app.example.com/oauth2/callback"
upstreams = ["http://app-backend:8080"]

cookie_secret_file = "/run/secrets/oauth2-proxy-cookie-secret"
cookie_secure = true
cookie_httponly = true
cookie_samesite = "lax"
cookie_expire = "4h"
cookie_refresh = "30m"

email_domains = ["example.com"]
allowed_groups = ["grp-app-users@example.com"]

skip_auth_routes = []

set_xauthrequest = true
set_authorization_header = true
pass_access_token = false
pass_user_headers = true

reverse_proxy = true
real_client_ip_header = "X-Forwarded-For"

skip_provider_button = true
silence_ping_user_agent = true

request_logging = true
auth_logging = true
standard_logging = true

The allowed_groups field restricts access to a specific IdP group. The proxy validates group membership on every session refresh (every cookie_refresh interval, here 30 minutes). A user removed from the group will lose access within that window.

set_xauthrequest = true causes the proxy to inject X-Auth-Request-User, X-Auth-Request-Email, and X-Auth-Request-Groups headers into upstream requests. The backend application reads these headers rather than performing its own authentication.

pass_access_token = false is deliberate. Do not forward the raw OAuth2 access token to the backend unless the backend explicitly needs to make downstream API calls with it. Forwarding tokens unnecessarily widens the token’s exposure surface.

Kubernetes Deployment

apiVersion: apps/v1
kind: Deployment
metadata:
  name: oauth2-proxy
  namespace: iap
spec:
  replicas: 2
  selector:
    matchLabels:
      app: oauth2-proxy
  template:
    metadata:
      labels:
        app: oauth2-proxy
    spec:
      securityContext:
        runAsNonRoot: true
        runAsUser: 65534
        seccompProfile:
          type: RuntimeDefault
      containers:
        - name: oauth2-proxy
          image: quay.io/oauth2-proxy/oauth2-proxy:v7.7.1
          args:
            - --config=/etc/oauth2-proxy/oauth2-proxy.cfg
          ports:
            - containerPort: 4180
          volumeMounts:
            - name: config
              mountPath: /etc/oauth2-proxy
              readOnly: true
            - name: secrets
              mountPath: /run/secrets
              readOnly: true
          readinessProbe:
            httpGet:
              path: /ping
              port: 4180
            initialDelaySeconds: 5
            periodSeconds: 10
      volumes:
        - name: config
          configMap:
            name: oauth2-proxy-config
        - name: secrets
          projected:
            sources:
              - secret:
                  name: oauth2-proxy-client-secret
              - secret:
                  name: oauth2-proxy-cookie-secret

The backend deployment’s NetworkPolicy locks it to accept connections only from the proxy:

apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: app-backend-ingress
  namespace: iap
spec:
  podSelector:
    matchLabels:
      app: app-backend
  policyTypes:
    - Ingress
  ingress:
    - from:
        - podSelector:
            matchLabels:
              app: oauth2-proxy
      ports:
        - protocol: TCP
          port: 8080

Envoy ext_authz for Request-Level Authorization

OAuth2 Proxy handles authentication (is this request from a valid user?). For fine-grained per-route authorization (is this user allowed to call POST /admin/users?) you need an external authorization service.

Envoy’s ext_authz HTTP filter calls an external gRPC or HTTP service on every request. That service inspects the request headers, checks policy, and returns allow or deny.

Envoy Configuration

static_resources:
  listeners:
    - name: ingress
      address:
        socket_address:
          address: 0.0.0.0
          port_value: 8443
      filter_chains:
        - filters:
            - name: envoy.filters.network.http_connection_manager
              typed_config:
                "@type": type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager
                stat_prefix: ingress_http
                http_filters:
                  - name: envoy.filters.http.ext_authz
                    typed_config:
                      "@type": type.googleapis.com/envoy.extensions.filters.http.ext_authz.v3.ExtAuthz
                      grpc_service:
                        envoy_grpc:
                          cluster_name: ext_authz_cluster
                        timeout: 2s
                      failure_mode_allow: false
                      with_request_body:
                        max_request_bytes: 8192
                        allow_partial_message: false
                      clear_route_cache: true
                  - name: envoy.filters.http.router
                    typed_config:
                      "@type": type.googleapis.com/envoy.extensions.filters.http.router.v3.Router
                route_config:
                  name: local_route
                  virtual_hosts:
                    - name: backend
                      domains: ["*"]
                      routes:
                        - match:
                            prefix: "/"
                          route:
                            cluster: app_backend

  clusters:
    - name: ext_authz_cluster
      type: STATIC
      typed_extension_protocol_options:
        envoy.extensions.upstreams.http.v3.HttpProtocolOptions:
          "@type": type.googleapis.com/envoy.extensions.upstreams.http.v3.HttpProtocolOptions
          explicit_http_config:
            http2_protocol_options: {}
      load_assignment:
        cluster_name: ext_authz_cluster
        endpoints:
          - lb_endpoints:
              - endpoint:
                  address:
                    socket_address:
                      address: 127.0.0.1
                      port_value: 9001
      connect_timeout: 1s

    - name: app_backend
      type: STRICT_DNS
      load_assignment:
        cluster_name: app_backend
        endpoints:
          - lb_endpoints:
              - endpoint:
                  address:
                    socket_address:
                      address: app-backend
                      port_value: 8080
      connect_timeout: 5s

failure_mode_allow: false is the critical safety setting. If the ext_authz service is unreachable or times out, the request is denied. The alternative (true) would cause every request to be permitted during an auth service outage, which defeats the purpose of the IAP.

Building the ext_authz Service

The authorization service receives a CheckRequest containing the full request headers. A minimal Go implementation:

package main

import (
    "context"
    "net"
    "strings"

    corev3 "github.com/envoyproxy/go-control-plane/envoy/config/core/v3"
    authv3 "github.com/envoyproxy/go-control-plane/envoy/service/auth/v3"
    typev3 "github.com/envoyproxy/go-control-plane/envoy/type/v3"
    "google.golang.org/grpc"
    "google.golang.org/grpc/codes"
    statuspb "google.golang.org/grpc/status"
)

type authServer struct{}

func (a *authServer) Check(ctx context.Context, req *authv3.CheckRequest) (*authv3.CheckResponse, error) {
    headers := req.Attributes.Request.Http.Headers
    method := req.Attributes.Request.Http.Method
    path := req.Attributes.Request.Http.Path

    userEmail := headers["x-auth-request-email"]
    userGroups := headers["x-auth-request-groups"]

    if userEmail == "" {
        return deny(typev3.StatusCode_Unauthorized, "missing identity header"), nil
    }

    if strings.HasPrefix(path, "/admin") && method != "GET" {
        if !strings.Contains(userGroups, "grp-app-admins@example.com") {
            return deny(typev3.StatusCode_Forbidden, "admin write access requires grp-app-admins"), nil
        }
    }

    deviceCert := headers["x-device-cert-serial"]
    if deviceCert == "" {
        return deny(typev3.StatusCode_Forbidden, "device certificate required"), nil
    }

    if !isDeviceCompliant(deviceCert) {
        return deny(typev3.StatusCode_Forbidden, "device posture check failed"), nil
    }

    return &authv3.CheckResponse{
        Status: statuspb.New(codes.OK, "").Proto(),
        HttpResponse: &authv3.CheckResponse_OkResponse{
            OkResponse: &authv3.OkHttpResponse{
                Headers: []*corev3.HeaderValueOption{
                    {Header: &corev3.HeaderValue{Key: "x-verified-user", Value: userEmail}},
                    {Header: &corev3.HeaderValue{Key: "x-device-serial", Value: deviceCert}},
                },
            },
        },
    }, nil
}

func deny(code typev3.StatusCode, msg string) *authv3.CheckResponse {
    return &authv3.CheckResponse{
        Status: statuspb.New(codes.PermissionDenied, msg).Proto(),
        HttpResponse: &authv3.CheckResponse_DeniedResponse{
            DeniedResponse: &authv3.DeniedHttpResponse{
                Status: &typev3.HttpStatus{Code: code},
            },
        },
    }
}

func isDeviceCompliant(serial string) bool {
    // Query your endpoint management API (Intune, Jamf, etc.)
    // Return true only if the device is enrolled, policy-compliant,
    // and the certificate serial matches an active enrollment record.
    return true
}

func main() {
    lis, _ := net.Listen("tcp", ":9001")
    s := grpc.NewServer()
    authv3.RegisterAuthorizationServer(s, &authServer{})
    s.Serve(lis)
}

The ext_authz service is the right layer to enforce policies that require cross-cutting context: path + method + user + device state all simultaneously. OAuth2 Proxy alone cannot do this because it does not inspect request paths or methods.

See OAuth2/OIDC hardening practices for how to correctly validate the JWT that oauth2-proxy receives from the identity provider before your IAP trusts the identity headers it forwards.

GCP Identity-Aware Proxy for GKE Workloads

Google Cloud’s managed IAP integrates directly with the GCP load balancer layer. When enabled on a backend service, GCP IAP intercepts all requests and performs Google account authentication before forwarding to your backend.

Enabling IAP on a GKE Ingress

GKE Ingress uses BackendConfig resources to configure backend service properties, including IAP.

apiVersion: cloud.google.com/v1
kind: BackendConfig
metadata:
  name: app-backend-iap-config
  namespace: production
spec:
  iap:
    enabled: true
    oauthclientCredentials:
      secretName: iap-oauth-client-secret
apiVersion: v1
kind: Service
metadata:
  name: app-backend
  namespace: production
  annotations:
    cloud.google.com/backend-config: '{"default": "app-backend-iap-config"}'
spec:
  selector:
    app: app-backend
  ports:
    - port: 80
      targetPort: 8080

The iap-oauth-client-secret must contain client_id and client_secret keys from an OAuth2 client created in the GCP Console under APIs & Services > Credentials. The client must be of type “Web application” and the IAP authorized redirect URIs are managed by GCP automatically.

After enabling IAP, grant access to specific principals:

gcloud iap web add-iam-policy-binding \
  --resource-type=backend-services \
  --service=app-backend \
  --member="group:grp-app-users@example.com" \
  --role="roles/iap.httpsResourceAccessor"

IAP-Secured Tunnels

GCP IAP also provides TCP tunneling for non-HTTP resources (SSH, database ports). This replaces bastion hosts:

gcloud compute start-iap-tunnel instance-name 22 \
  --local-host-port=localhost:2222 \
  --zone=us-central1-a

The tunnel authenticates the requesting user against IAP before establishing the TCP connection. All connection attempts are logged in Cloud Audit Logs under iap.googleapis.com.

Context-Aware Access Policies

GCP’s Access Context Manager lets you attach device posture conditions to IAP access levels:

resource "google_access_context_manager_access_level" "corp_device" {
  parent = "accessPolicies/${var.access_policy_id}"
  name   = "accessPolicies/${var.access_policy_id}/accessLevels/corp_device"
  title  = "Corporate device with endpoint verification"

  basic {
    conditions {
      device_policy {
        require_corp_owned     = true
        require_screen_lock    = true
        allowed_encryption_statuses = ["ENCRYPTED"]
        os_constraints {
          os_type             = "DESKTOP_CHROME_OS"
          minimum_version     = "114.0.0"
          require_verified_chrome_os = true
        }
      }
      required_access_levels = []
    }
  }
}

Bind the access level to a GCP IAP resource using VPC Service Controls or the IAM condition accesscontextmanager.googleapis.com/accessLevels attribute on the IAP IAM binding.

Pomerium: Self-Hosted IAP with Built-In Policy Engine

Pomerium is a self-hosted IAP that combines the functions of OAuth2 Proxy and an authorization engine. Its policy is expressed in a single configuration file rather than requiring a separate ext_authz service.

authenticate_service_url: https://authenticate.example.com

idp_provider: "google"
idp_client_id: "pomerium-iap"
idp_client_secret_file: "/run/secrets/pomerium-client-secret"

cookie_secret_file: "/run/secrets/pomerium-cookie-secret"
cookie_expire: 4h

signing_key_file: "/run/secrets/pomerium-signing-key"

routes:
  - from: https://app.example.com
    to: http://app-backend:8080
    policy:
      - allow:
          and:
            - groups:
                has: "grp-app-users@example.com"
            - device:
                is: managed

  - from: https://app.example.com/admin
    to: http://app-backend:8080
    prefix: /admin
    policy:
      - allow:
          and:
            - groups:
                has: "grp-app-admins@example.com"
            - device:
                is: managed
            - record:
                field: "device.is_compliant"
                is: "true"
    set_request_headers:
      X-Pomerium-Claim-Email: "{pomerium.claim_email}"
      X-Pomerium-Claim-Groups: "{pomerium.claim_groups}"

The device: is: managed condition checks whether the request originates from a device enrolled in Pomerium’s device enrollment flow (which uses device certificates). The record condition queries an external data source through Pomerium’s policy evaluator extensions.

Pomerium injects a signed JWT (X-Pomerium-Jwt-Assertion) into upstream requests that the backend can verify using Pomerium’s public signing key. This is more robust than trusting plain headers, because a backend can independently verify the signature.

Device Posture Integration

Device posture is the mechanism by which your IAP makes access decisions based not just on who the user is but on the state of the device they are using. An unmanaged personal laptop is structurally less trustworthy than a corporate device with disk encryption enforced, MDM enrollment verified, and endpoint agent running.

Three integration patterns exist:

1. mTLS with device certificates. The IAP requires clients to present a certificate from a device CA during TLS handshake. Only managed devices have certificates issued by the device CA. The certificate serial number is extracted and passed to the authorization service, which can verify it against your MDM enrollment database.

In Envoy, enable downstream mTLS on the listener:

transport_socket:
  name: envoy.transport_sockets.tls
  typed_config:
    "@type": type.googleapis.com/envoy.extensions.transport_sockets.tls.v3.DownstreamTlsContext
    require_client_certificate: true
    common_tls_context:
      validation_context:
        trusted_ca:
          filename: /etc/ssl/device-ca.crt

The ext_authz service then reads the x-forwarded-client-cert header that Envoy injects, parses the certificate serial, and queries the MDM API.

2. Endpoint agent header injection. Endpoint security agents (CrowdStrike, SentinelOne, Tanium) can be configured to inject signed headers into HTTP requests. The IAP verifies the header signature using the vendor’s published public key and reads the device compliance status from the header payload.

3. Device registration with periodic check-in. The IAP maintains a device registry. Devices register with a certificate or an agent-generated attestation token. On each request, the IAP looks up the device identifier in the registry and rejects requests from devices whose last compliance check-in exceeds a threshold (for example, 24 hours).

Hardening the IAP Itself

The IAP is a high-value target. If it can be bypassed or compromised, the entire access control model fails.

Protect the proxy’s own endpoints. OAuth2 Proxy exposes /oauth2/callback, /oauth2/sign_in, and /ping. The callback endpoint must only accept requests from the IdP’s redirect. Validate the state parameter on every callback. The ping/health endpoint should not be exposed on the public interface — use a separate internal listener or health check port bound to a private address.

Validate the Host header. The proxy must reject requests where the Host header does not match the configured upstream domain. Envoy’s virtual host domains field enforces this. Without it, an attacker who can route traffic to the proxy can potentially bypass route-based authorization checks by manipulating the Host header.

Strip inbound identity headers. Any headers that the backend trusts (X-Auth-Request-User, X-Forwarded-User, X-Pomerium-Jwt-Assertion) must be stripped from incoming requests before they reach the authentication layer. If an attacker can inject X-Auth-Request-Email: admin@example.com into a request that the backend trusts, they bypass the entire IAP. In OAuth2 Proxy this is handled automatically for its own injected headers. In Envoy, use a HeaderMutation filter or a Lua filter to strip untrusted inbound headers before the ext_authz check.

http_filters:
  - name: envoy.filters.http.lua
    typed_config:
      "@type": type.googleapis.com/envoy.extensions.filters.http.lua.v3.LuaPerRoute
      source_code:
        inline_string: |
          function envoy_on_request(request_handle)
            request_handle:headers():remove("x-auth-request-user")
            request_handle:headers():remove("x-auth-request-email")
            request_handle:headers():remove("x-auth-request-groups")
            request_handle:headers():remove("x-forwarded-user")
            request_handle:headers():remove("x-pomerium-jwt-assertion")
          end
  - name: envoy.filters.http.ext_authz
    ...

Audit logging. Every authorization decision must be logged with: timestamp, user identity, source IP, device identifier, HTTP method, path, decision (allow/deny), and policy rule matched. Do not log request bodies (risk of credential leakage in POST bodies), but log the authorization-relevant headers. Ship logs to an append-only destination (Cloud Logging, a WORM-enabled S3 bucket, a Loki instance with tamper-evident configuration) where the IAP process itself cannot delete them.

Rate-limit the authentication endpoints. The /oauth2/sign_in and OIDC callback endpoints should be rate-limited by source IP. An attacker who can send unlimited callback requests can attempt state parameter brute-force attacks. Envoy’s rate limit filter or an upstream WAF rule can enforce this.

Certificate pinning for the IdP connection. The IAP makes outbound HTTPS requests to the identity provider (JWKS endpoint, token endpoint, userinfo endpoint). Pin the IdP’s TLS certificate or CA. If the CA is compromised and a fraudulent certificate is issued for the IdP, a pinned IAP will reject the fraudulent certificate rather than accepting it. This is especially important in environments where local network appliances perform TLS inspection.

Run the proxy with a minimal process identity. The OAuth2 Proxy container should run as a non-root user (UID 65534), with readOnlyRootFilesystem: true, all Linux capabilities dropped, and a seccomp profile applied. The ext_authz service should have no network access to anything other than the MDM API and the Envoy socket — a NetworkPolicy can enforce this.

Summary

An IAP removes network position as a trust signal and makes identity — user, group, device — the sole basis for access decisions. The core pattern is always the same: all traffic through the proxy, no direct backend access, identity verified per request. OAuth2 Proxy handles the OIDC flow for simple cases. Envoy ext_authz provides the hook for complex per-route, per-method policy. GCP IAP offloads the operational burden when running on Google Cloud. Pomerium consolidates authentication and policy into a single self-hosted component. Device posture via mTLS or endpoint agents adds the machine dimension to access decisions.

The implementation is only as strong as the enforcement of the “no direct backend access” invariant. Network policies, firewall rules, and process-level binding to the loopback interface must all be in place, and they must be audited regularly. A proxy that can be bypassed is not an IAP — it is a speed bump.