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
- You front an internal app (admin console, Grafana, an internal API, a staging site) with an external HTTPS Load Balancer and want Google sign-in instead of a VPN or a bastion.
- You want identity-based access — “members of
platform-admins@may reach this,” not “anyone from this IP range” — with the authentication offloaded entirely to Google. - You run App Engine, a Cloud Run service behind a serverless NEG, a GKE Ingress, or a Compute backend, and need a consistent, reviewed way to gate it.
- You need the verified caller identity inside the app via the signed IAP JWT, without writing your own OAuth/OIDC handshake.
- You want IAP access grants in code and under review, scoped least-privilege, rather than clicked into the console per request.
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 config — live/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 config — live/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
- Treat the brand as a singleton you never destroy. A project allows only one
google_iap_brand, and the API can’t delete it, so create it once andterraform import(or setcreate_brand = falsewithexisting_brand_name) everywhere else. Pinapplication_titleandsupport_emailcarefully — the support email must be the project owner or a Group the deployer owns, or the apply fails. - Scope accessor bindings per backend service, not project-wide. Default to
scope = "backend_service"so a grant for the Grafana backend can’t silently open the Airflow backend. Reservescope = "project"for cases where every IAP-secured resource genuinely shares the same audience. - Grant Groups, layer conditions, and prefer least privilege. Put
group:members inmembers(never long lists ofuser:), and add an Access Context Managerconditionso identity and device/network posture are both required — this is the BeyondCorp pattern IAP exists to deliver. AvoidallAuthenticatedUsers, which lets any Google account in. - Keep the OAuth client secret out of logs and state diffs. The
oauth_client_secretoutput is markedsensitive; feed it straight into the LBiap{}block and neveroutputorechoit elsewhere. Store Terraform state in a backend with encryption and tight IAM. - Verify the signed JWT in your app. IAP authenticates the user, but your backend should still validate the
X-Goog-IAP-JWT-Assertionheader (audience = the backend service / OAuth client) so a request that bypasses the LB can’t impersonate a user. Restrict ingress / firewall so traffic can only arrive via the IAP-fronted load balancer. - Name and label for auditability. Give the OAuth client and backend services owner-prefixed names (
admin-console-backend,grafana-backend) and keep one module call per tool so the mapping of group → resource is obvious in both the plan output and the IAM policy.