Skip to main content

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")
bash

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:

  1. One widget per environment — the name and domains fields encode the environment-specific subdomain (e.g., access-dev.eigenoid.com).
  2. Secret flows through Terraform statesecret_data = cloudflare_turnstile_widget.X.secret. No manual gcloud secrets versions add. If the widget is recreated, the secret version is recreated automatically in the same apply.
  3. Sitekey is a non-sensitive outputoutput "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.
  4. 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_sitekey output to the PUBLIC_TURNSTILE_SITE_KEY Workers Builds environment variable for the affected Worker project. This step is documented in the layer README and the Environment Promotion Runbook.
  5. Required Cloudflare API token scopes: Account > Turnstile > Edit and Account > Account Settings > Read. Both scopes are required; the provider returns a bare 403 without 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-cloudflare issue tracker.
  • Terraform state contains the Turnstile secret key: the .secret attribute 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_TOKEN must carry Account > Turnstile > Edit + Account > Account Settings > Read before terraform apply foundation/. Documented in each affected IaC README and the Environment Promotion Runbook.
  • Widget mode and region are fixed at creation: changing mode or region forces a widget replacement (taint semantics). 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.tf binding + outputs.tf entry + 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_widget in the service/ layer instead of foundation/: rejected — the secret must be available in Secret Manager before the service/ layer applies (Cloud Run env var). Placing the widget in service/ would create a circular dependency on its own output.

References

  • Plan 154 R5 implementation: iac-access commit a4db390
  • cloudflare_turnstile_widget resource docs
  • iac-access/foundation/turnstile.tf — reference implementation
  • iac-access/foundation/README.md — operator guide
  • ADR-0008 — Terraform shared infra model (producer/consumer layer pattern)
Extended by ADR-0020

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.