| 1 | --- |
| 2 | title: Turnstile CAPTCHA |
| 3 | description: Manage Cloudflare Turnstile widgets as resources — deploy a widget, embed its sitekey in HTML, and verify tokens server-side with the redacted secret. |
| 4 | sidebar: |
| 5 | order: 23 |
| 6 | --- |
| 7 | |
| 8 | Turnstile is Cloudflare's CAPTCHA alternative. A Turnstile widget is |
| 9 | defined by the hostnames it runs on and its interaction mode; |
| 10 | Cloudflare assigns two keys in return — a public `sitekey` you embed |
| 11 | in HTML and a `secret` your server uses to verify the tokens the |
| 12 | widget produces. |
| 13 | |
| 14 | ## Create a widget |
| 15 | |
| 16 | ```typescript |
| 17 | // alchemy.run.ts |
| 18 | import * as Alchemy from "alchemy"; |
| 19 | import * as Cloudflare from "alchemy/Cloudflare"; |
| 20 | import * as Effect from "effect/Effect"; |
| 21 | |
| 22 | export default Alchemy.Stack( |
| 23 | "SignupApp", |
| 24 | { |
| 25 | providers: Cloudflare.providers(), |
| 26 | state: Cloudflare.state(), |
| 27 | }, |
| 28 | Effect.gen(function* () { |
| 29 | const widget = yield* Cloudflare.Turnstile.Widget("SignupForm", { |
| 30 | domains: ["example.com"], |
| 31 | mode: "managed", |
| 32 | }); |
| 33 | |
| 34 | return { |
| 35 | sitekey: widget.sitekey, |
| 36 | }; |
| 37 | }), |
| 38 | ); |
| 39 | ``` |
| 40 | |
| 41 | `domains` lists the hostnames the widget may run on — subdomains are |
| 42 | covered automatically. `mode` is `managed` (Cloudflare decides |
| 43 | whether an interaction is required), `non-interactive` (never asks), |
| 44 | or `invisible` (no visible widget). If you omit `name`, a unique one |
| 45 | is generated from the app, stage, and logical ID. |
| 46 | |
| 47 | ## The two keys |
| 48 | |
| 49 | ```typescript |
| 50 | const sitekey = widget.sitekey; // public — render it in your HTML |
| 51 | const secret = widget.secret; // Redacted<string> — server-side only |
| 52 | ``` |
| 53 | |
| 54 | The `sitekey` is public and safe to expose as a stack output. The |
| 55 | `secret` is a `Redacted<string>`: it never prints in logs or |
| 56 | JSON-serialized output (it renders as `"<redacted>"`), so unwrap it |
| 57 | with `Redacted.value` at the point where your server actually needs |
| 58 | the raw string. |
| 59 | |
| 60 | ## Embed the sitekey |
| 61 | |
| 62 | ```html |
| 63 | <script |
| 64 | src="https://challenges.cloudflare.com/turnstile/v0/api.js" |
| 65 | async |
| 66 | defer |
| 67 | ></script> |
| 68 | <div class="cf-turnstile" data-sitekey="<sitekey from the deploy output>"></div> |
| 69 | ``` |
| 70 | |
| 71 | The widget runs the challenge in the browser and puts the resulting |
| 72 | token in a hidden `cf-turnstile-response` form field (or hands it to |
| 73 | your callback). The token proves nothing by itself — your server must |
| 74 | verify it. |
| 75 | |
| 76 | ## Verify tokens server-side |
| 77 | |
| 78 | There is no runtime binding for Turnstile — verification is a plain |
| 79 | HTTPS call from any backend. POST the token together with |
| 80 | `Redacted.value(secret)` to Cloudflare's |
| 81 | `https://challenges.cloudflare.com/turnstile/v0/siteverify` endpoint |
| 82 | and check `success` in the response; see Cloudflare's |
| 83 | [server-side validation docs](https://developers.cloudflare.com/turnstile/get-started/server-side-validation/) |
| 84 | for the full request/response contract. A Worker is a natural place |
| 85 | to do this — pass the secret in via the Worker's `env`. |
| 86 | |
| 87 | ## Updates and replacement |
| 88 | |
| 89 | ```typescript |
| 90 | const widget = yield* Cloudflare.Turnstile.Widget("SignupForm", { |
| 91 | domains: ["example.com", "app.example.com"], |
| 92 | mode: "invisible", |
| 93 | }); |
| 94 | ``` |
| 95 | |
| 96 | Name, domains, mode, and the clearance settings all mutate in place — |
| 97 | the widget keeps its `sitekey` and `secret`, so deployed pages keep |
| 98 | working. Only `region` (default `"world"`) forces a replacement, |
| 99 | which mints a new sitekey and secret. If the widget is deleted |
| 100 | out-of-band, the next deploy observes it missing and recreates it |
| 101 | rather than failing. |
| 102 | |
| 103 | :::note |
| 104 | `botFightMode`, `ephemeralId`, and `offlabel` (hide Cloudflare |
| 105 | branding) are Enterprise-only settings; `clearanceLevel` controls the |
| 106 | challenge clearance granted when the widget is embedded on a |
| 107 | Cloudflare zone. |
| 108 | ::: |
| 109 | |
| 110 | ## Where next |
| 111 | |
| 112 | - [Workers](/cloudflare/compute/workers) — host your siteverify call next to |
| 113 | the form it protects. |
| 114 | - [Secrets & env](/cloudflare/security/secrets-env) — how the widget secret |
| 115 | gets into the Worker. |
| 116 | - [Widget API reference](/providers/cloudflare/turnstile/widget) |