Skip to content

Cloudflare Turnstile

captcha.turnstile() creates a Captcha guard backed by Cloudflare Turnstile. Layeron can create and maintain the Turnstile widget during deploy, or adopt a widget that already exists in the user’s Cloudflare account.

Use a stable name because Layeron uses it to track the widget, route guard, secret binding, sitekey binding, and deployment state.

Terminal window
import { backend } from "@layeron/core"
import { captcha } from "@layeron/modules"
const app = backend({ project: "shop" })
const signupCaptcha = captcha.turnstile({
name: "signup",
mode: "managed",
domains: ["app.example.com"],
action: "signup",
})

At deploy time, Layeron creates a cloudflare.turnstile.widget resource in the user’s Cloudflare account and injects the generated secret and sitekey into the Gateway Worker:

Terminal window
LAYERON_CAPTCHA_DEFAULT_SIGNUP_SECRET
LAYERON_CAPTCHA_DEFAULT_SIGNUP_SITEKEY

The public sitekey can be rendered in the browser. The secret is injected only into the runtime surface that performs Siteverify.

After deployment, print the generated public sitekey with the Layeron CLI:

Terminal window
layer captcha sitekeys --name signup

Provide the public sitekey and a Layeron secret reference when a Turnstile widget already exists.

Terminal window
const contactCaptcha = captcha.turnstile({
name: "contact",
sitekey: "0x4AAAA...",
secret: { kind: "secret_ref", name: "TURNSTILE_CONTACT_SECRET" },
domains: ["app.example.com"],
action: "contact",
})

Layeron still creates Gateway Worker bindings and runs the same server-side verification flow.

Add the guard to the route use list for automatic verification.

Terminal window
app.post("/signup", { use: [signupCaptcha] }, async () => {
return { ok: true }
})

Gateway extracts the token, calls Cloudflare Siteverify, validates hostname, action, and cData when configured, and runs the route handler after verification succeeds.

Cloudflare’s widget script writes cf-turnstile-response for normal HTML form submissions.

Terminal window
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>
<form method="post" action="/signup">
<input name="email" type="email" autocomplete="email" />
<div
class="cf-turnstile"
data-sitekey="$ENV.LAYERON_CAPTCHA_DEFAULT_SIGNUP_SITEKEY"
data-action="signup"
></div>
<button>Create account</button>
</form>

Default token sources:

Terminal window
{
header: "cf-turnstile-response",
form: "cf-turnstile-response",
json: "cfTurnstileResponse",
}

For JSON requests, submit the token in the configured JSON property:

Terminal window
await fetch("/api/signup", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
email,
cfTurnstileResponse: token,
}),
})

Use tokenFrom when the frontend uses different names.

Terminal window
captcha.turnstile({
name: "signup",
tokenFrom: {
header: "x-captcha-token",
json: "captchaToken",
},
})

layer dev configures Cloudflare Turnstile testing automatically. Managed and adopted Turnstile bindings receive Cloudflare’s always-pass dummy sitekey and secret in the local Worker environment:

Terminal window
LAYERON_CAPTCHA_DEFAULT_SIGNUP_SITEKEY=1x00000000000000000000AA
LAYERON_CAPTCHA_DEFAULT_SIGNUP_SECRET=1x0000000000000000000000000000000AA

Cloudflare dummy keys work on local hosts such as localhost, 127.0.0.1, and custom development domains. Layeron also accepts local hostnames during layer dev when a production domains allowlist is configured. Cloudflare testing responses can report example.com and omit action; Layeron recognizes Cloudflare’s testing metadata during layer dev and accepts the response for local development.

Set the binding name or the adopted widget secret reference in .dev.vars when you need a different local test key:

Terminal window
LAYERON_CAPTCHA_DEFAULT_SIGNUP_SITEKEY=2x00000000000000000000AB
TURNSTILE_SIGNUP_SECRET=2x0000000000000000000000000000000AA

Use manual verification when the handler controls the response shape or token location.

Terminal window
app.use(signupCaptcha)
app.post("/api/signup", async (request) => {
const body = await request.json()
const result = await signupCaptcha.verifyToken(body.captchaToken, {
request,
hostname: "app.example.com",
action: "signup",
})
if (!result.ok) {
return result.response()
}
return { ok: true }
})

Manual validation can override hostname, action, and cData for a single call.

  • Set domains for every public host that can submit tokens.
  • Set action for each user flow and keep it aligned with the widget data-action value.
  • Set cData when the client and server share a flow-specific custom data value.
  • Use separate Captcha instances for signup, contact, checkout, and password reset flows.
  • Keep adopted widget secrets in Layeron secret references.
  • Keep failure.body stable for clients.

missing_token means the route did not receive cf-turnstile-response or the configured token field.

hostname_mismatch means the returned hostname does not match domains or the request host.

action_mismatch means the widget data-action value does not match captcha.turnstile({ action }).

cdata_mismatch means the returned custom data does not match the configured cData.

invalid-input-response means Cloudflare rejected the token.