What is created, and where
pithy add turnstile wrote the wiring; pithy turnstile is the command; Using Turnstile is the gate itself; the reference has the keys.
| Environment | What it gets |
|---|---|
dev | Cloudflare’s documented test keys. No widget created |
staging | The same test keys. No widget created |
prod | A real widget, its sitekey written to config and its secret to the store |
Only production gets a real widget, and that is the design rather than a shortcut. The test keys need no Cloudflare round-trip and make the pass, block and forced-challenge paths trivially testable.
The test keys
Cloudflare publishes them, and the kit wires the always-pass pair:
| Visible, always passes | 1x00000000000000000000AA |
| Invisible, always passes | 1x00000000000000000000BB |
| Visible, always blocks | 2x00000000000000000000AB |
| Always forces an interaction | 3x00000000000000000000FF |
The sitekeys are public — the front end renders with them. The secrets feed siteverify.
The test key answers with no action, and the gate knows
This is the one exception to the action binding, and without it dev and staging sign-in was impossible.
Cloudflare’s always-pass secret answers success: true with no action field at all. The auth capability stacks its gate with an expected action, so the binding compared that action against nothing and denied every sign-in in exactly the two environments provisioning wires the test key into — a token that was valid, refused by a field the key never populates.
The exception needs all three of:
- Cloudflare says it is a test key — its own flag on its own answer, not a comparison against a list of key strings the kit keeps. A real widget’s answer never carries it, so no real deployment can reach this branch however its secret is spelled.
- No action came back at all. An action that came back and differs is a token minted for another action — exactly what the binding exists to refuse, and it is refused here too.
- The Worker says it is dev or staging — read off the stamped environment var, which nothing in a request can influence.
prodis not in that list, and neither is an unstamped Worker.
The alternative was to relax the binding itself, which would have traded a real protection — a token solved for one action must not be replayable against another — for a developer’s convenience.
The widget’s name carries the project
<project>-prod-turnstile-<mode>.
Provisioning is reuse-or-create by name, and Turnstile widgets are account-scoped with a flat list. An unscoped name means a second Pithy project in one account adopts the first’s widget — and turnstile deprovision deletes it out from under them.
prod sits in the environment slot because that is the environment this widget serves, and it is the only one.
Three writes that must agree
The environment appears in the widget’s own name, in the secret write, and in the sitekey write.
A widget provisioned under one environment and a secret written under another is a production login page verifying against a key nobody holds. So the environment is named once in the code rather than spelled at each call site.
What the front end reads
The public sitekey for each mode is surfaced as a Worker var — TURNSTILE_SITEKEY_VISIBLE, TURNSTILE_SITEKEY_INVISIBLE — and reaches the browser through the client projection, indexed by environment name.