Skip to content
mdlake

Turnstile

Add Cloudflare Turnstile to a form as an extra bot check, with the keys, the test keys for development, and what happens when a check fails. For developers.

On this page

Turnstile is Cloudflare’s bot check. When it is on, the form shows a small widget that decides in the visitor’s browser whether this looks like a person. The widget puts a token in the form. The server sends that token and a secret key to Cloudflare, and sends the mail only if Cloudflare accepts it.

It is off by default. The honeypot and the rate limit already stop simple scripts. Add Turnstile when a form attracts real abuse, or when the client asks for it. It loads a script from Cloudflare, so tell the client, and check their privacy text. The ruleset forbids adding anything that tracks visitors unless the client asks.

Important

Turnstile needs JavaScript. A visitor with JavaScript off sees a note that the form needs JavaScript to check that they are not a robot, and the server refuses the post.

What you’ll do

  1. Turn Turnstile on for a form.
  2. Create a Turnstile widget in Cloudflare and copy its two keys.
  3. Set the keys where the site builds and where it runs.

Turn it on for a form

For a new form, pass --turnstile:

Bash
npx mdlake add-form contact --turnstile

For an existing form, set turnstile: true in src/forms/<name>.ts:

TypeScript
export default defineForm({
  // ...schema, fields, recipientEnv, subject, success
  turnstile: true,
})

Turnstile is a property of one form. Other forms on the same site are not affected.

The keys

Cloudflare gives each widget a pair of keys.

KeyVariableSecretRead when
Site keyPUBLIC_TURNSTILE_SITE_KEYNo. It is written into the page.Build time. Change it and you must build and deploy again.
Secret keyTURNSTILE_SECRET_KEYYesRun time, on the server.

To get them, add a Turnstile widget for the site’s domain in your Cloudflare account, and copy the site key and the secret key. The names of Cloudflare’s menus change from time to time, so follow Cloudflare’s own Turnstile documentation for the clicks.

Where to put them:

WhereSite keySecret key
Local .envOptional. Leave both unset to use the test keys.Optional.
GitHub ActionsA repository variable PUBLIC_TURNSTILE_SITE_KEY. The generated workflow passes it to npm run check, which builds the site.Not needed in the pipeline.
Azure DevOpsA pipeline variable PUBLIC_TURNSTILE_SITE_KEY.Not needed in the pipeline.
Azure (running site)Not needed at run time.TURNSTILE_SECRET_KEY in Key Vault. hosting init adds it to secretNames in the parameter files when a form asks for Turnstile.

If you change the site key after the site is built, the page still carries the old one. Rebuild.

Test keys for development

You do not need a Cloudflare account to work on the site. When the keys are not set, development uses Cloudflare’s published test keys, which always pass:

KeyValueEffect
Site key, always passes1x00000000000000000000AAThe widget succeeds.
Site key, always fails2x00000000000000000000ABThe widget reports failure.
Site key, forces an interactive challenge3x00000000000000000000FFThe widget asks the visitor to interact.
Secret, always passes1x0000000000000000000000000000000AACloudflare accepts any token.
Secret, always fails2x0000000000000000000000000000000AACloudflare rejects every token.
Secret, token already spent3x0000000000000000000000000000000AACloudflare says the token was used.

The kit applies them like this:

  • The dev server (npm run dev) uses the always-passing site key when PUBLIC_TURNSTILE_SITE_KEY is not set.
  • The Node handler uses the always-passing secret when TURNSTILE_SECRET_KEY is not set and NODE_ENV is not production. The generated Bicep sets NODE_ENV=production.
  • The .NET backend uses the always-passing secret when the secret is not set and the environment is Development.

To see the failure path locally, put the always-fails keys in your .env.

Warning

Never ship a test key. A production build without PUBLIC_TURNSTILE_SITE_KEY stops with an error, and a production server without TURNSTILE_SECRET_KEY answers 500. That is deliberate: the check fails closed.

What happens when a check fails

Turnstile never lets a submission through by accident. The server answers:

SituationStatus and codeWhat the visitor sees
No token in the post (JavaScript off, widget blocked)403 captcha-failed“We could not confirm that you are not a robot. Go back and try again.”
Cloudflare rejects the token403 captcha-failedThe same message.
Cloudflare cannot be reached, or answers with an error502 captcha-unavailable“We could not check that you are not a robot. Please try again in a moment.”
TURNSTILE_SECRET_KEY is not set in production500 captcha-not-configured“This form is not set up yet. Please try again later.” The log says which variable is missing.

When the browser gets an error after sending, the form resets the widget so the visitor can try again with a fresh token.

The build also checks the site key. A form with turnstile: true fails the build with “The form “contact” uses Turnstile, but PUBLIC_TURNSTILE_SITE_KEY is not set for this build.” when no key is set outside the dev server.

How it works

  1. The Form component loads https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit once and renders the widget inside the form. The widget adds a hidden field named cf-turnstile-response.
  2. The browser posts that field with the rest of the form.
  3. After the schema accepts the values, the server posts the secret, the token and, when known, the visitor’s address to https://challenges.cloudflare.com/turnstile/v0/siteverify.
  4. Only success: true continues to the mail step.

If the site sets a Content Security Policy, allow challenges.cloudflare.com for scripts, frames and connections.

For tests, TURNSTILE_VERIFY_URL replaces the Cloudflare address (the kit’s tests use a stand-in server). The .NET backend reads the same variable. Do not set it in production.

What can go wrong

SymptomFix
The build stops: “uses Turnstile, but PUBLIC_TURNSTILE_SITE_KEY is not set”Set the variable for the build (a repository variable in CI), not only on the server.
The widget never appearsThe script is blocked by an ad blocker or a policy. Check the browser console and the Content Security Policy.
403 on every send in productionThe secret and the site key belong to different widgets, or the widget’s domain list does not include your domain.
500 captcha-not-configuredTURNSTILE_SECRET_KEY is missing from the App Service settings or Key Vault.
Works locally, fails after deploymentYou tested with the test keys. Set both real keys.

What’s next