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
- You serve a public web app or API and want score-based bot scoring wired into sign-up, login, checkout, or comment-post actions.
- You need per-environment site keys (dev/staging/prod) created identically and tracked in state, not hand-built in the console.
- You are protecting native mobile apps and need Android (package + SHA-256) or iOS (bundle ID) keys alongside your web key.
- You want reCAPTCHA scores consumed by Cloud Armor as a WAF token (
waf_settingsbound to theCAservice) for edge bot mitigation. - You want the allowed-domains list and integration type to be code-reviewed, since loosening either is effectively a security control change.
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 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/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
- One key per platform per environment. Never share a prod site key with staging or local dev — mixed traffic corrupts the adaptive risk model and makes prod scores unreliable. Drive the split with the
envlabel and a distinctdisplay_name. - Lock down
allowed_domains/ package names / bundle IDs. Leaveallow_all_domains = falsein production; the module’s validation forces you to enumerate them. An open domain list lets attackers farm valid tokens from their own sites. - Prefer
integration_type = "SCORE"for adaptive defense. Score-based keys give you a continuous 0.0–1.0 signal you can threshold per action, rather than the binary friction of a checkbox; reserveCHECKBOX/INVISIBLEfor flows that genuinely need an interactive challenge. - Control cost at the assessment, not the key. reCAPTCHA Enterprise bills per assessment (
createAssessmentcall), so only call it on high-risk actions (login, signup, checkout) — not every page view — and watch the free monthly assessment allowance before enabling it estate-wide. - Bind to Cloud Armor for edge mitigation. Setting
waf_settingswithwaf_feature = "ACTION_TOKEN"lets the WAF reject bots before they hit your origin, which is cheaper and faster than backend-only verification. - Use
testing_optionsonly in non-prod. Fixed test scores make CI deterministic, but a key withtesting_scoreset returns that score for all traffic — never apply it to a production key.