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.
Create A Managed Widget
Section titled “Create A Managed Widget”Use a stable name because Layeron uses it to track the widget, route guard,
secret binding, sitekey binding, and deployment state.
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:
LAYERON_CAPTCHA_DEFAULT_SIGNUP_SECRETLAYERON_CAPTCHA_DEFAULT_SIGNUP_SITEKEYThe 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:
layer captcha sitekeys --name signupAdopt An Existing Widget
Section titled “Adopt An Existing Widget”Provide the public sitekey and a Layeron secret reference when a Turnstile
widget already exists.
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.
Protect A Route
Section titled “Protect A Route”Add the guard to the route use list for automatic verification.
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.
Submit The Token
Section titled “Submit The Token”Cloudflare’s widget script writes cf-turnstile-response for normal HTML form
submissions.
<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:
{ header: "cf-turnstile-response", form: "cf-turnstile-response", json: "cfTurnstileResponse",}For JSON requests, submit the token in the configured JSON property:
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.
captcha.turnstile({ name: "signup", tokenFrom: { header: "x-captcha-token", json: "captchaToken", },})Local Development
Section titled “Local Development”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:
LAYERON_CAPTCHA_DEFAULT_SIGNUP_SITEKEY=1x00000000000000000000AALAYERON_CAPTCHA_DEFAULT_SIGNUP_SECRET=1x0000000000000000000000000000000AACloudflare 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:
LAYERON_CAPTCHA_DEFAULT_SIGNUP_SITEKEY=2x00000000000000000000ABTURNSTILE_SIGNUP_SECRET=2x0000000000000000000000000000000AAUse Manual Verification
Section titled “Use Manual Verification”Use manual verification when the handler controls the response shape or token location.
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.
Production Checklist
Section titled “Production Checklist”- Set
domainsfor every public host that can submit tokens. - Set
actionfor each user flow and keep it aligned with the widgetdata-actionvalue. - Set
cDatawhen 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.bodystable for clients.
Troubleshooting
Section titled “Troubleshooting”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.