IaC GCP

Terraform Module: GCP Identity-Aware Proxy (IAP) — Zero-Trust Access in Front of Your Load Balancer

Quick take — A reusable hashicorp/google ~> 5.0 module for google_iap_brand and google_iap_web_iam_member: provision the OAuth brand, IAP OAuth client, and least-privilege accessor bindings that put Google sign-in in front of any HTTPS backend. New here? Jump to the Quickstart below to deploy it in minutes; read on for how it works and when to reach for it.

Quickstart (copy-paste)

Minimal, runnable configuration — drop this in a .tf file and fill in the "..." placeholders (each required input is commented):

provider "google" {
  project = "my-project"
  region  = "us-central1"
}

module "iap" {
  source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-gcp-iap?ref=v1.0.0"

  project_id = "..."  # Project that owns the brand and IAP resources.
}

Then terraform init && terraform apply. Every other input has a sensible default — see Inputs below to override behaviour.

What this module is

Identity-Aware Proxy (IAP) is GCP’s zero-trust access layer. Instead of putting an application behind a VPN or trusting a network perimeter, you flip IAP on at the load balancer and Google itself authenticates every request before it ever reaches your backend. A user hitting the URL is bounced to a Google sign-in page; only after they prove their identity and hold the roles/iap.httpsResourceAccessor role on that resource does the request get proxied through. Your application receives a signed X-Goog-IAP-JWT-Assertion header with the verified identity, and unauthenticated traffic never touches your code. It is the same context-aware access model Google uses internally under BeyondCorp.

The friction is that turning IAP on cleanly is a multi-step, easy-to-fumble sequence. You first need an OAuth brand (the consent screen) for the project — there can only ever be one brand per project, and once created it cannot be deleted via the API, so a careless terraform destroy leaves orphaned state. You then need an IAP-specific OAuth client, and finally the IAM bindings that say who is allowed through. Get the brand support email wrong (it must be the project owner or a Google Group the deployer owns), forget that internal brands behave differently from public ones, or hand-roll the accessor bindings per team, and you end up with either a locked-out app or an over-permissioned one.

This module wraps google_iap_brand and google_iap_web_iam_member (plus the OAuth client and, optionally, the per-backend-service binding) into one variable-driven block. It provisions the consent screen once, mints an IAP OAuth client, and grants the exact list of users, groups, and service accounts the accessor role — either across the whole project’s IAP surface or scoped to a single backend service. The OAuth client ID and secret come back as outputs so your HTTPS load balancer’s iap block can consume them.

When to use it

Skip it if the resource must be reachable by anonymous public users (a marketing site), if you need TCP/SSH-style access to VMs (that is IAP TCP forwarding, a different resource — google_iap_tunnel_instance_iam_member), or if your identity provider can’t federate into Google / Cloud Identity at all.

Module structure

terraform-module-gcp-iap/
├── versions.tf      # provider + required_version pins
├── main.tf          # brand (consent screen), OAuth client, accessor IAM bindings
├── variables.tf     # var-driven inputs with validation
└── outputs.tf       # brand name, OAuth client id/secret, accessor members

versions.tf

terraform {
  required_version = ">= 1.5.0"

  required_providers {
    google = {
      source  = "hashicorp/google"
      version = "~> 5.0"
    }
  }
}

main.tf

# The OAuth consent screen ("brand"). A project can have AT MOST ONE brand,
# and the API does not support deleting it — so this resource is created once
# and, in practice, imported on re-runs rather than recreated. support_email
# must be the project owner or a Google Group the deployer is an owner of.
resource "google_iap_brand" "this" {
  count = var.create_brand ? 1 : 0

  project           = var.project_id
  support_email     = var.support_email
  application_title = var.application_title
}

locals {
  # Use the brand we created, or an existing one passed in. The brand name has
  # the form "projects/<number>/brands/<number>".
  brand_name = var.create_brand ? google_iap_brand.this[0].name : var.existing_brand_name
}

# The IAP-specific OAuth client. Its client_id/secret are what the HTTPS load
# balancer's iap{} block references to enable IAP on a backend service.
resource "google_iap_client" "this" {
  count = var.create_oauth_client ? 1 : 0

  display_name = var.oauth_client_display_name
  brand        = local.brand_name
}

# Project-wide accessor bindings: who may pass through IAP for ANY IAP-secured
# web resource in the project (App Engine, all backend services, etc.).
resource "google_iap_web_iam_member" "members" {
  for_each = var.scope == "project" ? toset(var.members) : toset([])

  project = var.project_id
  role    = "roles/iap.httpsResourceAccessor"
  member  = each.value

  # Optional condition, e.g. time-bound or Access Context Manager level.
  dynamic "condition" {
    for_each = var.condition == null ? [] : [var.condition]
    content {
      title       = condition.value.title
      description = lookup(condition.value, "description", null)
      expression  = condition.value.expression
    }
  }
}

# Backend-service-scoped accessor bindings: who may pass through IAP for ONE
# specific load balancer backend service only. Preferred for least privilege.
resource "google_iap_web_backend_service_iam_member" "members" {
  for_each = var.scope == "backend_service" ? toset(var.members) : toset([])

  project             = var.project_id
  web_backend_service = var.backend_service_name
  role                = "roles/iap.httpsResourceAccessor"
  member              = each.value

  dynamic "condition" {
    for_each = var.condition == null ? [] : [var.condition]
    content {
      title       = condition.value.title
      description = lookup(condition.value, "description", null)
      expression  = condition.value.expression
    }
  }
}

variables.tf

variable "project_id" {
  description = "GCP project ID that owns the IAP brand and resources."
  type        = string
}

variable "create_brand" {
  description = "Create the OAuth brand (consent screen). Set false to reuse the project's existing brand (only one brand per project is allowed)."
  type        = bool
  default     = true
}

variable "existing_brand_name" {
  description = "Existing brand resource name (projects/<number>/brands/<number>) to use when create_brand = false."
  type        = string
  default     = null

  validation {
    condition     = var.create_brand || var.existing_brand_name != null
    error_message = "existing_brand_name is required when create_brand is false."
  }
}

variable "support_email" {
  description = "Support email shown on the consent screen. MUST be the project owner or a Google Group the deployer owns."
  type        = string
  default     = null

  validation {
    condition     = !var.create_brand || (var.support_email != null && can(regex("^[^@]+@[^@]+\\.[^@]+$", var.support_email)))
    error_message = "support_email must be a valid email and is required when create_brand is true."
  }
}

variable "application_title" {
  description = "Application name displayed to users on the IAP consent screen."
  type        = string
  default     = null

  validation {
    condition     = !var.create_brand || var.application_title != null
    error_message = "application_title is required when create_brand is true."
  }
}

variable "create_oauth_client" {
  description = "Create an IAP OAuth client whose id/secret the load balancer's iap{} block consumes."
  type        = bool
  default     = true
}

variable "oauth_client_display_name" {
  description = "Display name for the IAP OAuth client."
  type        = string
  default     = "iap-oauth-client"
}

variable "scope" {
  description = "Granularity of accessor bindings: 'project' (all IAP web resources) or 'backend_service' (one LB backend only)."
  type        = string
  default     = "backend_service"

  validation {
    condition     = contains(["project", "backend_service"], var.scope)
    error_message = "scope must be either 'project' or 'backend_service'."
  }
}

variable "backend_service_name" {
  description = "Name of the load balancer backend service to scope IAP access to. Required when scope = 'backend_service'."
  type        = string
  default     = null

  validation {
    condition     = var.scope != "backend_service" || var.backend_service_name != null
    error_message = "backend_service_name is required when scope is 'backend_service'."
  }
}

variable "members" {
  description = "IAM members granted roles/iap.httpsResourceAccessor, e.g. user:a@b.com, group:team@b.com, serviceAccount:svc@proj.iam.gserviceaccount.com, domain:b.com."
  type        = list(string)
  default     = []

  validation {
    condition = alltrue([
      for m in var.members :
      can(regex("^(user|group|serviceAccount|domain|principal|principalSet):", m)) || m == "allAuthenticatedUsers"
    ])
    error_message = "Each member must be prefixed (user:/group:/serviceAccount:/domain:/principal:/principalSet:) or be 'allAuthenticatedUsers'."
  }
}

variable "condition" {
  description = "Optional IAM condition (CEL) on the accessor bindings, e.g. an Access Context Manager level or a time bound. { title, description, expression }."
  type = object({
    title       = string
    description = optional(string)
    expression  = string
  })
  default = null
}

outputs.tf

output "brand_name" {
  description = "Resource name of the OAuth brand (projects/<number>/brands/<number>)."
  value       = local.brand_name
}

output "brand_id" {
  description = "Brand ID (the trailing numeric segment of the brand name), or null when reusing an existing brand."
  value       = var.create_brand ? google_iap_brand.this[0].brand_id : null
}

output "oauth_client_id" {
  description = "IAP OAuth client ID for the load balancer's iap{} block. Null when create_oauth_client is false."
  value       = var.create_oauth_client ? google_iap_client.this[0].client_id : null
}

output "oauth_client_secret" {
  description = "IAP OAuth client secret. Feed into the LB iap{} block; treat as sensitive."
  value       = var.create_oauth_client ? google_iap_client.this[0].secret : null
  sensitive   = true
}

output "accessor_members" {
  description = "Members granted the IAP accessor role by this module."
  value       = var.members
}

How to use it

# A Cloud Run service exposed through an external HTTPS LB via a serverless NEG.
# The backend service is where IAP is actually toggled on.
resource "google_compute_region_network_endpoint_group" "console_neg" {
  project               = var.project_id
  name                  = "admin-console-neg"
  region                = "asia-south1"
  network_endpoint_type = "SERVERLESS"

  cloud_run {
    service = "admin-console"
  }
}

module "identity_aware_proxy_iap_admin" {
  source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-gcp-iap?ref=v1.0.0"

  project_id        = var.project_id
  support_email     = "platform-owners@kloudvin.com" # a Group the deployer owns
  application_title = "KloudVin Admin Console"

  # Gate exactly this one backend service, not the whole project.
  scope                = "backend_service"
  backend_service_name = google_compute_backend_service.console.name

  # Only the platform team may pass through IAP.
  members = [
    "group:platform-admins@kloudvin.com",
    "user:vinod@kloudvin.com",
  ]

  # Require the corporate-network access level in addition to identity.
  condition = {
    title      = "corp-network-only"
    expression = "\"accessPolicies/123456789/accessLevels/corp_network\" in request.auth.access_levels"
  }
}

# The backend service that carries the IAP toggle. The module's OAuth client
# id/secret are wired straight into the iap{} block.
resource "google_compute_backend_service" "console" {
  project    = var.project_id
  name       = "admin-console-backend"
  protocol   = "HTTPS"
  port_name  = "https"
  enable_cdn = false

  backend {
    group = google_compute_region_network_endpoint_group.console_neg.id
  }

  iap {
    enabled              = true
    oauth2_client_id     = module.identity_aware_proxy_iap_admin.oauth_client_id     # <- module output
    oauth2_client_secret = module.identity_aware_proxy_iap_admin.oauth_client_secret # <- module output (sensitive)
  }
}

With Terragrunt

Terragrunt keeps this module DRY across environments — define the backend and provider once in a root config, then a thin terragrunt.hcl per environment supplies only the inputs that differ.

1. Root configlive/terragrunt.hcl (inherited by every module):

remote_state {
  backend = "gcs"
  generate = { path = "backend.tf", if_exists = "overwrite" }
  config = {
    # ...gcs state bucket/container + key per path...
  }
}

2. Module configlive/prod/iap/terragrunt.hcl:

include "root" {
  path = find_in_parent_folders()
}

terraform {
  source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-gcp-iap?ref=v1.0.0"
}

inputs = {
  project_id = "..."
}

3. Deploy one environment, or roll out all modules together:

cd live/prod/iap && terragrunt apply        # this module
terragrunt run-all apply                      # every module under live/prod

Why Terragrunt here: the backend and provider live in one place instead of being copy-pasted into every module; inputs is overridden per environment (dev / stage / prod) without forking the module; and run-all orchestrates dependencies across modules. Reach for it once you have more than one environment or more than a handful of modules — for a single stack, the plain Quickstart above is enough.

Inputs

Name Type Default Required Description
project_id string yes Project that owns the brand and IAP resources.
create_brand bool true no Create the OAuth consent screen (one brand per project).
existing_brand_name string null no Existing brand projects/<n>/brands/<n> when create_brand = false.
support_email string null conditional Consent-screen support email; project owner or owned Group. Required when creating the brand.
application_title string null conditional App name shown on the consent screen. Required when creating the brand.
create_oauth_client bool true no Mint an IAP OAuth client for the LB iap{} block.
oauth_client_display_name string iap-oauth-client no Display name of the IAP OAuth client.
scope string backend_service no project (all IAP web resources) or backend_service (one LB backend).
backend_service_name string null conditional Backend service to gate. Required when scope = backend_service.
members list(string) [] no Members granted roles/iap.httpsResourceAccessor (prefixed).
condition object null no Optional CEL IAM condition {title, description, expression} (e.g. an Access Context Manager level).

Outputs

Name Description
brand_name Brand resource name projects/<number>/brands/<number>.
brand_id Numeric brand ID, or null when reusing an existing brand.
oauth_client_id IAP OAuth client ID for the LB iap{} block.
oauth_client_secret IAP OAuth client secret (sensitive).
accessor_members Members this module granted the IAP accessor role.

Enterprise scenario

A SaaS company runs roughly a dozen internal tools — Grafana, an Airflow UI, a feature-flag console, and several staging environments — each behind one shared external HTTPS Load Balancer with a backend service per tool. Rather than a VPN, the platform team enables IAP and calls this module once per backend service, granting each tool’s owning Google Group the accessor role at scope = "backend_service" and attaching a condition that requires an Access Context Manager level for corporate-managed devices. Because the brand is created exactly once (with create_brand = true only in the first invocation and existing_brand_name thereafter), and every access grant is a reviewed Terraform change, an auditor can see precisely which group reaches which internal app — and revoking a departing engineer is a one-line diff, not a console hunt.

Best practices

TerraformGCPIdentity-Aware Proxy (IAP)ModuleIaC
Need this built for real?

Vinod is a Senior Cloud Architect (22+ yrs) — available for Azure / AWS / GCP architecture, landing zones, and migrations.

Work with me

Comments

Keep Reading