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
- Turn Turnstile on for a form.
- Create a Turnstile widget in Cloudflare and copy its two keys.
- Set the keys where the site builds and where it runs.
Turn it on for a form
For a new form, pass --turnstile:
npx mdlake add-form contact --turnstileFor an existing form, set turnstile: true in src/forms/<name>.ts:
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.
| Key | Variable | Secret | Read when |
|---|---|---|---|
| Site key | PUBLIC_TURNSTILE_SITE_KEY | No. It is written into the page. | Build time. Change it and you must build and deploy again. |
| Secret key | TURNSTILE_SECRET_KEY | Yes | Run 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:
| Where | Site key | Secret key |
|---|---|---|
Local .env | Optional. Leave both unset to use the test keys. | Optional. |
| GitHub Actions | A 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 DevOps | A 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:
| Key | Value | Effect |
|---|---|---|
| Site key, always passes | 1x00000000000000000000AA | The widget succeeds. |
| Site key, always fails | 2x00000000000000000000AB | The widget reports failure. |
| Site key, forces an interactive challenge | 3x00000000000000000000FF | The widget asks the visitor to interact. |
| Secret, always passes | 1x0000000000000000000000000000000AA | Cloudflare accepts any token. |
| Secret, always fails | 2x0000000000000000000000000000000AA | Cloudflare rejects every token. |
| Secret, token already spent | 3x0000000000000000000000000000000AA | Cloudflare says the token was used. |
The kit applies them like this:
- The dev server (
npm run dev) uses the always-passing site key whenPUBLIC_TURNSTILE_SITE_KEYis not set. - The Node handler uses the always-passing secret when
TURNSTILE_SECRET_KEYis not set andNODE_ENVis notproduction. The generated Bicep setsNODE_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:
| Situation | Status and code | What 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 token | 403 captcha-failed | The same message. |
| Cloudflare cannot be reached, or answers with an error | 502 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 production | 500 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
- The
Formcomponent loadshttps://challenges.cloudflare.com/turnstile/v0/api.js?render=explicitonce and renders the widget inside the form. The widget adds a hidden field namedcf-turnstile-response. - The browser posts that field with the rest of the form.
- 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. - Only
success: truecontinues 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
| Symptom | Fix |
|---|---|
| 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 appears | The script is blocked by an ad blocker or a policy. Check the browser console and the Content Security Policy. |
| 403 on every send in production | The secret and the site key belong to different widgets, or the widget’s domain list does not include your domain. |
500 captcha-not-configured | TURNSTILE_SECRET_KEY is missing from the App Service settings or Key Vault. |
| Works locally, fails after deployment | You tested with the test keys. Set both real keys. |
What’s next
- Forms for the rest of the form options.
- GitHub Actions and Azure DevOps for the build variable.
