IaC GCP

Terraform Module: GCP reCAPTCHA Enterprise — bot defense keys as versioned, environment-scoped IaC

Quick take — Provision GCP reCAPTCHA Enterprise keys (web, Android, iOS) with Terraform using hashicorp/google ~> 5.0 — score-based or challenge integration, WAF settings, and labels, all var-driven and reusable. 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 "recaptcha_enterprise" {
  source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-gcp-recaptcha-enterprise?ref=v1.0.0"

  project_id   = "..."  # GCP project ID in which the key is created.
  display_name = "..."  # Console display name (1–60 chars).
}

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

What this module is

reCAPTCHA Enterprise is Google Cloud’s fraud-prevention service that scores the likelihood that a given web or mobile interaction is human versus automated. Unlike the free reCAPTCHA v2/v3, the Enterprise tier gives you per-action risk scores (0.0 = almost certainly a bot, 1.0 = almost certainly human), reason codes, account-defender and password-leak signals, and the option to plug scores directly into Cloud Armor WAF rules. The unit you create and manage is a key — a google_recaptcha_enterprise_key — and each key is bound to a specific platform (web, Android, or iOS) and a specific integration mode (invisible score-based vs. checkbox/challenge).

In a real GCP estate you rarely have just one key. You typically need a separate site key per environment (dev, staging, prod) so that staging traffic never pollutes prod’s adaptive risk model, plus distinct keys for your web frontend and each mobile app. Click-opsing these in the console means the allowed-domain list, integration type, and WAF feature flag drift between environments and nobody can tell why staging suddenly throws challenges that prod does not. Wrapping google_recaptcha_enterprise_key in a reusable module makes the platform type, integration mode, allowed domains/bundle-IDs, and WAF service binding explicit, validated, and versioned — so a prod key is provably configured the same way as the one you tested in staging.

When to use it

Skip it if a single throwaway test key created by hand is all you need, or if the free non-Enterprise reCAPTCHA already meets your requirements — Enterprise is a paid, assessment-billed service.

Module structure

terraform-module-gcp-recaptcha-enterprise/
├── versions.tf      # provider + Terraform version constraints
├── main.tf          # google_recaptcha_enterprise_key resource
├── variables.tf     # var-driven inputs with validations
└── outputs.tf       # key id/name + platform attributes

versions.tf

terraform {
  required_version = ">= 1.3.0"

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

main.tf

locals {
  # Exactly one platform settings block must be populated; this enforces it.
  platform = (
    var.web_settings != null ? "web" :
    var.android_settings != null ? "android" :
    var.ios_settings != null ? "ios" :
    "none"
  )
}

resource "google_recaptcha_enterprise_key" "this" {
  display_name = var.display_name
  project      = var.project_id
  labels       = var.labels

  # --- Web keys -------------------------------------------------------------
  dynamic "web_settings" {
    for_each = var.web_settings != null ? [var.web_settings] : []
    content {
      integration_type              = web_settings.value.integration_type
      allow_all_domains             = web_settings.value.allow_all_domains
      allowed_domains               = web_settings.value.allow_all_domains ? null : web_settings.value.allowed_domains
      allow_amp_traffic             = web_settings.value.allow_amp_traffic
      challenge_security_preference = web_settings.value.challenge_security_preference
    }
  }

  # --- Android keys ---------------------------------------------------------
  dynamic "android_settings" {
    for_each = var.android_settings != null ? [var.android_settings] : []
    content {
      allow_all_package_names = android_settings.value.allow_all_package_names
      allowed_package_names   = android_settings.value.allow_all_package_names ? null : android_settings.value.allowed_package_names
    }
  }

  # --- iOS keys -------------------------------------------------------------
  dynamic "ios_settings" {
    for_each = var.ios_settings != null ? [var.ios_settings] : []
    content {
      allow_all_bundle_ids = ios_settings.value.allow_all_bundle_ids
      allowed_bundle_ids   = ios_settings.value.allow_all_bundle_ids ? null : ios_settings.value.allowed_bundle_ids
    }
  }

  # --- Optional WAF binding (Cloud Armor / Fastly) --------------------------
  dynamic "waf_settings" {
    for_each = var.waf_settings != null ? [var.waf_settings] : []
    content {
      waf_service = waf_settings.value.waf_service
      waf_feature = waf_settings.value.waf_feature
    }
  }

  # --- Optional automated test signals (lets CI bypass scoring) -------------
  dynamic "testing_options" {
    for_each = var.testing_options != null ? [var.testing_options] : []
    content {
      testing_score     = var.testing_options.testing_score
      testing_challenge = var.testing_options.testing_challenge
    }
  }

  lifecycle {
    precondition {
      condition     = local.platform != "none"
      error_message = "Exactly one of web_settings, android_settings, or ios_settings must be set."
    }
  }
}

variables.tf

variable "project_id" {
  description = "GCP project ID in which the reCAPTCHA Enterprise key is created."
  type        = string
}

variable "display_name" {
  description = "Human-readable name for the key, shown in the reCAPTCHA Enterprise console."
  type        = string

  validation {
    condition     = length(var.display_name) > 0 && length(var.display_name) <= 60
    error_message = "display_name must be between 1 and 60 characters."
  }
}

variable "labels" {
  description = "Key/value labels applied to the key (e.g. env, owner, app)."
  type        = map(string)
  default     = {}
}

variable "web_settings" {
  description = <<-EOT
    Settings for a WEB key. Set this OR android_settings OR ios_settings (exactly one).
    integration_type: SCORE (invisible, score-only) or CHECKBOX / INVISIBLE (challenge-capable).
  EOT
  type = object({
    integration_type              = string
    allow_all_domains             = optional(bool, false)
    allowed_domains               = optional(list(string), [])
    allow_amp_traffic             = optional(bool, false)
    challenge_security_preference = optional(string, "BALANCE")
  })
  default = null

  validation {
    condition = var.web_settings == null ? true : contains(
      ["SCORE", "CHECKBOX", "INVISIBLE"], var.web_settings.integration_type
    )
    error_message = "web_settings.integration_type must be one of SCORE, CHECKBOX, or INVISIBLE."
  }

  validation {
    condition = var.web_settings == null ? true : contains(
      ["USABILITY", "BALANCE", "SECURITY"], var.web_settings.challenge_security_preference
    )
    error_message = "web_settings.challenge_security_preference must be USABILITY, BALANCE, or SECURITY."
  }

  validation {
    condition = (
      var.web_settings == null ? true :
      var.web_settings.allow_all_domains || length(var.web_settings.allowed_domains) > 0
    )
    error_message = "Set allow_all_domains = true or provide at least one entry in allowed_domains."
  }
}

variable "android_settings" {
  description = "Settings for an ANDROID key. Set this OR web_settings OR ios_settings (exactly one)."
  type = object({
    allow_all_package_names = optional(bool, false)
    allowed_package_names   = optional(list(string), [])
  })
  default = null

  validation {
    condition = (
      var.android_settings == null ? true :
      var.android_settings.allow_all_package_names || length(var.android_settings.allowed_package_names) > 0
    )
    error_message = "Set allow_all_package_names = true or provide at least one allowed_package_names entry."
  }
}

variable "ios_settings" {
  description = "Settings for an iOS key. Set this OR web_settings OR android_settings (exactly one)."
  type = object({
    allow_all_bundle_ids = optional(bool, false)
    allowed_bundle_ids   = optional(list(string), [])
  })
  default = null

  validation {
    condition = (
      var.ios_settings == null ? true :
      var.ios_settings.allow_all_bundle_ids || length(var.ios_settings.allowed_bundle_ids) > 0
    )
    error_message = "Set allow_all_bundle_ids = true or provide at least one allowed_bundle_ids entry."
  }
}

variable "waf_settings" {
  description = <<-EOT
    Optional WAF binding so the key issues a WAF token.
    waf_service: CA (Cloud Armor) or FASTLY.
    waf_feature: CHALLENGE_PAGE, SESSION_TOKEN, ACTION_TOKEN, EXPRESS, or POLICY_BASED_CHALLENGE.
  EOT
  type = object({
    waf_service = string
    waf_feature = string
  })
  default = null

  validation {
    condition     = var.waf_settings == null ? true : contains(["CA", "FASTLY"], var.waf_settings.waf_service)
    error_message = "waf_settings.waf_service must be CA or FASTLY."
  }

  validation {
    condition = var.waf_settings == null ? true : contains(
      ["CHALLENGE_PAGE", "SESSION_TOKEN", "ACTION_TOKEN", "EXPRESS", "POLICY_BASED_CHALLENGE"],
      var.waf_settings.waf_feature
    )
    error_message = "waf_settings.waf_feature is not a recognised value."
  }
}

variable "testing_options" {
  description = "Optional fixed test outcomes so CI/E2E suites get deterministic scores or challenges."
  type = object({
    testing_score     = optional(number, null)
    testing_challenge = optional(string, null)
  })
  default = null

  validation {
    condition = (
      var.testing_options == null || var.testing_options.testing_score == null ? true :
      var.testing_options.testing_score >= 0.0 && var.testing_options.testing_score <= 1.0
    )
    error_message = "testing_options.testing_score must be between 0.0 and 1.0."
  }
}

outputs.tf

output "id" {
  description = "Fully-qualified key identifier (projects/<project>/keys/<key_id>)."
  value       = google_recaptcha_enterprise_key.this.id
}

output "name" {
  description = "Server-assigned key name / site key — embed this in your frontend grecaptcha call."
  value       = google_recaptcha_enterprise_key.this.name
}

output "display_name" {
  description = "Human-readable display name of the key."
  value       = google_recaptcha_enterprise_key.this.display_name
}

output "platform" {
  description = "Resolved platform for this key: web, android, or ios."
  value       = local.platform
}

output "create_time" {
  description = "Timestamp at which the key was created."
  value       = google_recaptcha_enterprise_key.this.create_time
}

output "labels" {
  description = "Labels applied to the key."
  value       = google_recaptcha_enterprise_key.this.labels
}

How to use it

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

  project_id   = "kloudvin-prod"
  display_name = "kloudvin-web-prod"

  labels = {
    env   = "prod"
    app   = "storefront"
    owner = "platform-security"
  }

  web_settings = {
    integration_type              = "SCORE" # invisible, score-based bot detection
    allow_all_domains             = false
    allowed_domains               = ["kloudvin.com", "www.kloudvin.com", "checkout.kloudvin.com"]
    challenge_security_preference = "SECURITY"
  }

  # Issue a WAF action token Cloud Armor can evaluate at the edge.
  waf_settings = {
    waf_service = "CA"
    waf_feature = "ACTION_TOKEN"
  }
}

# Downstream: surface the site key to the frontend build via Secret Manager
# so the SPA can call grecaptcha.enterprise.execute(siteKey, { action }).
resource "google_secret_manager_secret" "recaptcha_site_key" {
  project   = "kloudvin-prod"
  secret_id = "recaptcha-web-site-key"

  labels = { app = "storefront" }

  replication {
    auto {}
  }
}

resource "google_secret_manager_secret_version" "recaptcha_site_key" {
  secret      = google_secret_manager_secret.recaptcha_site_key.id
  secret_data = module.recaptcha_enterprise.name # the site key output from the module
}

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

include "root" {
  path = find_in_parent_folders()
}

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

inputs = {
  project_id = "..."
  display_name = "..."
}

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

cd live/prod/recaptcha_enterprise && 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 in which the key is created.
display_name string Yes Console display name (1–60 chars).
labels map(string) {} No Key/value labels (env, owner, app).
web_settings object null One of the three Web key config: integration_type (SCORE/CHECKBOX/INVISIBLE), allow_all_domains, allowed_domains, allow_amp_traffic, challenge_security_preference.
android_settings object null One of the three Android key config: allow_all_package_names, allowed_package_names.
ios_settings object null One of the three iOS key config: allow_all_bundle_ids, allowed_bundle_ids.
waf_settings object null No WAF binding: waf_service (CA/FASTLY) + waf_feature (ACTION_TOKEN, SESSION_TOKEN, CHALLENGE_PAGE, EXPRESS, POLICY_BASED_CHALLENGE).
testing_options object null No Deterministic CI outcomes: testing_score (0.0–1.0) and/or testing_challenge.

Outputs

Name Description
id Fully-qualified key id (projects/<project>/keys/<key_id>).
name Server-assigned key name / site key to embed in the frontend grecaptcha call.
display_name Human-readable display name of the key.
platform Resolved platform: web, android, or ios.
create_time Timestamp at which the key was created.
labels Labels applied to the key.

Enterprise scenario

A retail platform runs a public storefront plus Android and iOS apps. The platform-security team calls this module three times per environment — a SCORE web key bound to Cloud Armor with waf_feature = "ACTION_TOKEN", an Android key pinned to the production package name, and an iOS key pinned to the production bundle ID. Login, account-creation, and checkout actions ship the resulting site key, and a Cloud Armor edge policy challenges any request whose reCAPTCHA action token falls below a risk threshold — cutting credential-stuffing attempts before they ever reach the backend, with prod and staging keys provably configured identically because both came from ref=v1.0.0.

Best practices

TerraformGCPreCAPTCHA EnterpriseModuleIaC
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