Context
Cloudflare Turnstile widgets require two credentials: a sitekey (public, embedded in frontend code) and a secret key (server-side, used to verify challenge tokens). Before Plan 154 R5, the secret key had to be populated manually into GCP Secret Manager after each terraform apply foundation/:
# Old manual step — error-prone and skippable
gcloud secrets versions add access-turnstile-secret-key \
--data-file=<(echo -n "$TURNSTILE_SECRET_KEY")
This manual step was a frequent source of "service wave fails because turnstile secret has no version" incidents. It also meant the Turnstile widget could be recreated in Terraform (e.g., after a taint) without automatically rotating the secret in Secret Manager.
The Cloudflare Terraform provider (cloudflare/cloudflare) has supported the cloudflare_turnstile_widget resource since v5.x. The resource exposes .secret and .sitekey as computed attributes.
We have applied this pattern in iac-access/foundation/ and will need it in at least one other stack (iac-studio, if Studio adds a public-facing login flow). A consistent pattern should be established now.
Decision
Manage Cloudflare Turnstile widgets in the foundation layer of each IaC stack that needs them, using cloudflare_turnstile_widget from provider cloudflare/cloudflare. Pipe the widget's .secret attribute directly into a google_secret_manager_secret_version resource in the same layer. Expose the .sitekey as a non-sensitive output (sensitive = false).
Concretely:
- One widget per environment — the
nameanddomainsfields encode the environment-specific subdomain (e.g.,access-dev.eigenoid.com). - Secret flows through Terraform state —
secret_data = cloudflare_turnstile_widget.X.secret. No manualgcloud secrets versions add. If the widget is recreated, the secret version is recreated automatically in the same apply. - Sitekey is a non-sensitive output —
output "turnstile_sitekey" { sensitive = false }. The sitekey is intentionally public (it appears in frontend JavaScript). Marking it sensitive would obscure it in CI logs without adding security. - Sitekey → Workers Builds env var is a manual step — the Cloudflare provider v5.19.0 does not expose Workers Builds variables as a Terraform resource. After each apply, operators must copy the
turnstile_sitekeyoutput to thePUBLIC_TURNSTILE_SITE_KEYWorkers Builds environment variable for the affected Worker project. This step is documented in the layer README and the Environment Promotion Runbook. - Required Cloudflare API token scopes:
Account > Turnstile > EditandAccount > Account Settings > Read. Both scopes are required; the provider returns a bare403without a resource-name hint if either is missing.
Consequences
- Manual step eliminated for secret rotation: widget recreation (e.g., after
terraform taint) automatically propagates the new secret key to Secret Manager in the same apply. No post-apply manual step for the secret. - Sitekey propagation still requires a manual CF Dashboard step: until the Cloudflare provider exposes Workers Builds variables, operators must paste the sitekey into each Worker project after apply. This is a known limitation of provider v5.19.0, tracked in the
cloudflare/terraform-provider-cloudflareissue tracker. - Terraform state contains the Turnstile secret key: the
.secretattribute is stored in Terraform state. State must be treated as sensitive (GCS bucket with encryption at rest and uniform bucket-level access — already enforced by the auto-bootstrap step). This is no different from how other secrets (session keys, download tokens) are already managed. - Token scope requirement is a new operational precondition (E6):
CLOUDFLARE_API_TOKENmust carryAccount > Turnstile > Edit+Account > Account Settings > Readbeforeterraform apply foundation/. Documented in each affected IaC README and the Environment Promotion Runbook. - Widget mode and region are fixed at creation: changing
modeorregionforces a widget replacement (taintsemantics). Plan this carefully in production — existing tokens issued against the old widget will be invalidated. - Reusable pattern for future stacks: any IaC stack that adds a bot-protection layer to a public portal follows the same file layout (
turnstile.tf+secrets.tfbinding +outputs.tfentry + CF variable variables).
Alternatives considered
- Manual secret population (pre-Plan 154 approach): rejected — it is skippable, creates a deploy dependency on human memory, and does not auto-rotate when the widget is replaced.
- Cloudflare Worker environment secrets for the secret key: rejected — the secret key must be consumed by
svc-access(a GCP Cloud Run service), not by the CF Worker. Storing it as a Worker secret would require an additional cross-platform secret synchronization mechanism. - Store sitekey in Secret Manager too: rejected — the sitekey is intentionally public and appears in JavaScript bundles. There is no security value in wrapping it in Secret Manager; it would add unnecessary complexity (another secret read on service startup) with no benefit.
- Use
cloudflare_turnstile_widgetin theservice/layer instead offoundation/: rejected — the secret must be available in Secret Manager before theservice/layer applies (Cloud Run env var). Placing the widget inservice/would create a circular dependency on its own output.
References
- Plan 154 R5 implementation:
iac-accesscommita4db390 cloudflare_turnstile_widgetresource docsiac-access/foundation/turnstile.tf— reference implementationiac-access/foundation/README.md— operator guide- ADR-0008 — Terraform shared infra model (producer/consumer layer pattern)
ADR-0020 extends this decision with a use_test_turnstile_keys Terraform toggle for dev/qa environments and a precondition guard that blocks test keys in prd.