IaC GCP

Terraform Module: GCP Identity Platform — drop-in customer auth (CIAM) for your apps

Quick take — Wrap google_identity_platform_config in a reusable Terraform module: enable email/MFA sign-in, blocking functions, authorized domains, and quotas for production-grade CIAM on GCP. 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 "identity_platform" {
  source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-gcp-identity-platform?ref=v1.0.0"

  project_id = "..."  # GCP project ID where Identity Platform is configured.
}

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

What this module is

Google Cloud Identity Platform is GCP’s managed customer identity and access management (CIAM) service — the productized, enterprise-grade sibling of Firebase Authentication. It gives your web and mobile apps fully-hosted sign-in: email/password, phone, anonymous, OIDC, SAML and social providers, plus multi-factor authentication, blocking Cloud Functions, and SMS/email quota controls — without you running an auth server.

The catch is that the project-level configuration of Identity Platform lives in a single, somewhat awkward singleton resource: google_identity_platform_config. Enabling Identity Platform on a project is itself a one-time action (you flip on the identitytoolkit.googleapis.com API and “upgrade” the project), and the config resource then governs sign-in methods, MFA enforcement, authorized domains, anti-abuse quotas, and the blocking-function triggers that run during sign-up/sign-in.

Wrapping it in a module matters because this is exactly the kind of config that is easy to get subtly wrong and dangerous to drift: forget to lock down authorized domains and you invite redirect abuse; leave SMS region allow-lists open and you eat SMS-pumping fraud; toggle MFA per-environment by hand and prod ends up weaker than staging. A module makes the auth posture declarative, reviewable, and identical across dev/staging/prod, with the safety rails (validations, sane defaults) baked in once.

When to use it

Module structure

terraform-module-gcp-identity-platform/
├── versions.tf      # provider + Terraform version pins
├── main.tf          # project services + google_identity_platform_config
├── variables.tf     # var-driven inputs with validation
└── outputs.tf       # config id/name + key attributes
# versions.tf
terraform {
  required_version = ">= 1.5.0"

  required_providers {
    google = {
      source  = "hashicorp/google"
      version = "~> 5.0"
    }
  }
}
# main.tf

locals {
  # Identity Platform depends on the Identity Toolkit API being enabled.
  required_services = toset([
    "identitytoolkit.googleapis.com",
  ])
}

# Ensure the underlying API is on before configuring it. Without this, the
# google_identity_platform_config apply races the API enablement and fails.
resource "google_project_service" "identity_platform" {
  for_each = var.enable_required_apis ? local.required_services : toset([])

  project                    = var.project_id
  service                    = each.value
  disable_dependent_services = false
  disable_on_destroy         = false
}

resource "google_identity_platform_config" "this" {
  project = var.project_id

  # Domains allowed to receive OAuth redirects / action-code links.
  # Keep this tight: stale or wildcard-ish entries enable redirect abuse.
  authorized_domains = var.authorized_domains

  # Allow new end-users to self-register. Set false to make sign-up
  # admin-only (invite flows, internal B2B portals).
  autodelete_anonymous_users = var.autodelete_anonymous_users

  # Project-wide anti-abuse quota on new account creation.
  dynamic "quota" {
    for_each = var.signup_quota == null ? [] : [var.signup_quota]
    content {
      sign_up_quota_config {
        quota          = quota.value.quota
        start_time     = quota.value.start_time
        quota_duration = quota.value.quota_duration
      }
    }
  }

  # Multi-factor authentication posture (TOTP / SMS second factor).
  dynamic "mfa" {
    for_each = var.mfa == null ? [] : [var.mfa]
    content {
      state = mfa.value.state
      dynamic "provider_configs" {
        for_each = mfa.value.enable_totp ? [1] : []
        content {
          state = mfa.value.state
          totp_provider_config {
            adjacent_intervals = mfa.value.totp_adjacent_intervals
          }
        }
      }
    }
  }

  # Email / password + link sign-in.
  dynamic "sign_in" {
    for_each = var.sign_in == null ? [] : [var.sign_in]
    content {
      allow_duplicate_emails = sign_in.value.allow_duplicate_emails

      dynamic "email" {
        for_each = sign_in.value.email_enabled ? [1] : []
        content {
          enabled           = true
          password_required = sign_in.value.email_password_required
        }
      }

      dynamic "phone_number" {
        for_each = sign_in.value.phone_enabled ? [1] : []
        content {
          enabled            = true
          test_phone_numbers = sign_in.value.test_phone_numbers
        }
      }

      dynamic "anonymous" {
        for_each = sign_in.value.anonymous_enabled ? [1] : []
        content {
          enabled = true
        }
      }
    }
  }

  # SMS region allow/deny — the single most effective control against
  # SMS-pumping fraud. Default policy below is "deny all, allow listed".
  dynamic "sms_region_config" {
    for_each = length(var.allowed_sms_regions) == 0 ? [] : [1]
    content {
      allow_by_default {
        disallowed_regions = []
      }
      allowlist_only {
        allowed_regions = var.allowed_sms_regions
      }
    }
  }

  # Blocking functions: run a Cloud Function before sign-up / before sign-in.
  dynamic "blocking_functions" {
    for_each = length(var.blocking_function_triggers) == 0 ? [] : [1]
    content {
      dynamic "triggers" {
        for_each = var.blocking_function_triggers
        content {
          event_type   = triggers.key            # "beforeCreate" | "beforeSignIn"
          function_uri = triggers.value
        }
      }
      forward_inbound_credentials {
        id_token     = var.forward_id_token
        access_token = var.forward_access_token
        refresh_token = var.forward_refresh_token
      }
    }
  }

  depends_on = [google_project_service.identity_platform]
}
# variables.tf

variable "project_id" {
  description = "GCP project ID where Identity Platform is configured."
  type        = string

  validation {
    condition     = can(regex("^[a-z][a-z0-9-]{4,28}[a-z0-9]$", var.project_id))
    error_message = "project_id must be a valid GCP project ID (6-30 lowercase alphanumerics/hyphens)."
  }
}

variable "enable_required_apis" {
  description = "Enable identitytoolkit.googleapis.com on the project. Disable if managed elsewhere."
  type        = bool
  default     = true
}

variable "authorized_domains" {
  description = "Domains allowed to receive OAuth redirects and email action links (e.g. app.example.com)."
  type        = list(string)
  default     = ["localhost"]

  validation {
    condition = alltrue([
      for d in var.authorized_domains : can(regex("^[a-zA-Z0-9.-]+$", d))
    ])
    error_message = "authorized_domains must contain only hostnames (no scheme, path, or wildcards)."
  }
}

variable "autodelete_anonymous_users" {
  description = "Automatically purge anonymous users after 30 days of inactivity."
  type        = bool
  default     = true
}

variable "signup_quota" {
  description = "Anti-abuse quota on new account creation over a sliding window. null disables."
  type = object({
    quota          = number
    start_time     = string # RFC3339, e.g. "2026-06-09T00:00:00Z"
    quota_duration = string # seconds with 's' suffix, e.g. "7200s"
  })
  default = null

  validation {
    condition     = var.signup_quota == null ? true : var.signup_quota.quota > 0
    error_message = "signup_quota.quota must be a positive number."
  }
}

variable "mfa" {
  description = "Multi-factor authentication configuration."
  type = object({
    state                   = string       # "DISABLED" | "ENABLED" | "MANDATORY"
    enable_totp             = optional(bool, true)
    totp_adjacent_intervals = optional(number, 1)
  })
  default = {
    state = "ENABLED"
  }

  validation {
    condition     = contains(["DISABLED", "ENABLED", "MANDATORY"], var.mfa.state)
    error_message = "mfa.state must be one of DISABLED, ENABLED, MANDATORY."
  }
}

variable "sign_in" {
  description = "Sign-in methods to enable for end-users."
  type = object({
    allow_duplicate_emails  = optional(bool, false)
    email_enabled           = optional(bool, true)
    email_password_required = optional(bool, true)
    phone_enabled           = optional(bool, false)
    test_phone_numbers      = optional(map(string), {})
    anonymous_enabled       = optional(bool, false)
  })
  default = {
    email_enabled = true
  }
}

variable "allowed_sms_regions" {
  description = "ISO-3166-1 alpha-2 country codes allowed to receive SMS (allowlist-only). Empty = no SMS region restriction."
  type        = list(string)
  default     = []

  validation {
    condition = alltrue([
      for r in var.allowed_sms_regions : can(regex("^[A-Z]{2}$", r))
    ])
    error_message = "allowed_sms_regions must be two-letter uppercase ISO country codes (e.g. IN, US, GB)."
  }
}

variable "blocking_function_triggers" {
  description = "Map of event type to Cloud Function URI, e.g. { beforeCreate = \"https://...\" }."
  type        = map(string)
  default     = {}

  validation {
    condition = alltrue([
      for k in keys(var.blocking_function_triggers) : contains(["beforeCreate", "beforeSignIn"], k)
    ])
    error_message = "blocking_function_triggers keys must be 'beforeCreate' or 'beforeSignIn'."
  }
}

variable "forward_id_token" {
  description = "Forward the user's ID token to blocking functions."
  type        = bool
  default     = false
}

variable "forward_access_token" {
  description = "Forward the OAuth access token to blocking functions."
  type        = bool
  default     = false
}

variable "forward_refresh_token" {
  description = "Forward the OAuth refresh token to blocking functions."
  type        = bool
  default     = false
}
# outputs.tf

output "config_id" {
  description = "Fully-qualified resource ID of the Identity Platform config."
  value       = google_identity_platform_config.this.id
}

output "config_name" {
  description = "Resource name of the config (projects/{project}/config)."
  value       = google_identity_platform_config.this.name
}

output "project_id" {
  description = "Project ID that Identity Platform is configured on."
  value       = google_identity_platform_config.this.project
}

output "authorized_domains" {
  description = "Effective list of authorized domains for OAuth redirects and action links."
  value       = google_identity_platform_config.this.authorized_domains
}

output "mfa_state" {
  description = "Effective MFA enforcement state (DISABLED / ENABLED / MANDATORY)."
  value       = try(google_identity_platform_config.this.mfa[0].state, "DISABLED")
}

How to use it

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

  project_id         = "kv-shop-prod"
  authorized_domains = ["shop.kloudvin.com", "account.kloudvin.com"]

  # Make MFA mandatory in production, email+password as the primary method.
  mfa = {
    state       = "MANDATORY"
    enable_totp = true
  }

  sign_in = {
    email_enabled           = true
    email_password_required = true
    phone_enabled           = true
    anonymous_enabled       = false
  }

  # Only allow SMS to the regions you actually serve — blocks SMS pumping.
  allowed_sms_regions = ["IN", "US", "GB", "AE"]

  # Anti-abuse: cap new signups to 1000 per 2-hour window.
  signup_quota = {
    quota          = 1000
    start_time     = "2026-06-09T00:00:00Z"
    quota_duration = "7200s"
  }

  # Screen new accounts with a blocking Cloud Function (e.g. domain allow-list).
  blocking_function_triggers = {
    beforeCreate = google_cloudfunctions2_function.auth_gate.url
  }
  forward_id_token = true
}

# Downstream: wire the config name into a monitoring alert / IAM audit doc.
resource "google_monitoring_alert_policy" "auth_signups" {
  display_name = "Identity Platform signup spike — ${module.identity_platform.project_id}"
  combiner     = "OR"

  conditions {
    display_name = "High signup rate"
    condition_threshold {
      filter          = "resource.type=\"identitytoolkit.googleapis.com/Project\" AND metric.type=\"identitytoolkit.googleapis.com/identity_toolkit_request_count\""
      comparison      = "COMPARISON_GT"
      threshold_value = 500
      duration        = "300s"
      aggregations {
        alignment_period   = "60s"
        per_series_aligner = "ALIGN_RATE"
      }
    }
  }

  # Reference an output so the alert always tracks the managed config.
  user_labels = {
    config = replace(module.identity_platform.config_name, "/", "_")
  }
}

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/identity_platform/terragrunt.hcl:

include "root" {
  path = find_in_parent_folders()
}

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

inputs = {
  project_id = "..."
}

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

cd live/prod/identity_platform && 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 GCP project ID where Identity Platform is configured.
enable_required_apis bool true No Enable identitytoolkit.googleapis.com on the project.
authorized_domains list(string) ["localhost"] No Domains allowed for OAuth redirects and email action links.
autodelete_anonymous_users bool true No Auto-purge anonymous users after 30 days of inactivity.
signup_quota object null No Anti-abuse quota on new account creation over a sliding window.
mfa object { state = "ENABLED" } No MFA posture: DISABLED / ENABLED / MANDATORY, with optional TOTP.
sign_in object { email_enabled = true } No Sign-in methods: email, phone, anonymous, and duplicate-email policy.
allowed_sms_regions list(string) [] No ISO-3166-1 alpha-2 codes allowed to receive SMS (allowlist-only).
blocking_function_triggers map(string) {} No Map of beforeCreate/beforeSignIn to a Cloud Function URI.
forward_id_token bool false No Forward the user’s ID token to blocking functions.
forward_access_token bool false No Forward the OAuth access token to blocking functions.
forward_refresh_token bool false No Forward the OAuth refresh token to blocking functions.

Outputs

Name Description
config_id Fully-qualified resource ID of the Identity Platform config.
config_name Resource name of the config (projects/{project}/config).
project_id Project ID that Identity Platform is configured on.
authorized_domains Effective list of authorized domains for redirects and action links.
mfa_state Effective MFA enforcement state (DISABLED / ENABLED / MANDATORY).

Enterprise scenario

A fintech runs a customer wallet app across three GCP projects (-dev, -staging, -prod) and must prove to PCI auditors that production auth is strictly harder than lower environments. They consume this module from a single root module with a per-environment tfvars: prod pins mfa.state = "MANDATORY", restricts allowed_sms_regions to the four markets they operate in, caps signups via signup_quota, and routes beforeCreate to a blocking function that rejects disposable-email domains. Because the entire posture is in version control, an attempted “temporary” MFA downgrade shows up as a Terraform diff in code review and never reaches prod silently.

Best practices

TerraformGCPIdentity PlatformModuleIaC
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